Gemini
Initial commit
fce10de
package ghmcp
import (
"context"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
mcplog "github.com/github/github-mcp-server/pkg/log"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
gogithub "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
)
type MCPServerConfig struct {
// Version of the server
Version string
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
Host string
// GitHub Token to authenticate with the GitHub API
Token string
// EnabledToolsets is a list of toolsets to enable
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string
// Whether to enable dynamic toolsets
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
DynamicToolsets bool
// ReadOnly indicates if we should only offer read-only tools
ReadOnly bool
// Translator provides translated text for the server tooling
Translator translations.TranslationHelperFunc
// Content window size
ContentWindowSize int
}
const stdioServerLogPrefix = "stdioserver"
func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
apiHost, err := parseAPIHost(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
}
// Construct our REST client
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
restClient.BaseURL = apiHost.baseRESTURL
restClient.UploadURL = apiHost.uploadURL
// Construct our GraphQL client
// We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already
// did the necessary API host parsing so that github.com will return the correct URL anyway.
gqlHTTPClient := &http.Client{
Transport: &bearerAuthTransport{
transport: http.DefaultTransport,
token: cfg.Token,
},
} // We're going to wrap the Transport later in beforeInit
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
// When a client send an initialize request, update the user agent to include the client info.
beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
userAgent := fmt.Sprintf(
"github-mcp-server/%s (%s/%s)",
cfg.Version,
message.Params.ClientInfo.Name,
message.Params.ClientInfo.Version,
)
restClient.UserAgent = userAgent
gqlHTTPClient.Transport = &userAgentTransport{
transport: gqlHTTPClient.Transport,
agent: userAgent,
}
}
hooks := &server.Hooks{
OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
OnBeforeAny: []server.BeforeAnyHookFunc{
func(ctx context.Context, _ any, _ mcp.MCPMethod, _ any) {
// Ensure the context is cleared of any previous errors
// as context isn't propagated through middleware
errors.ContextWithGitHubErrors(ctx)
},
},
}
ghServer := github.NewServer(cfg.Version, server.WithHooks(hooks))
enabledToolsets := cfg.EnabledToolsets
if cfg.DynamicToolsets {
// filter "all" from the enabled toolsets
enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets))
for _, toolset := range cfg.EnabledToolsets {
if toolset != "all" {
enabledToolsets = append(enabledToolsets, toolset)
}
}
}
getClient := func(_ context.Context) (*gogithub.Client, error) {
return restClient, nil // closing over client
}
getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
return gqlClient, nil // closing over client
}
getRawClient := func(ctx context.Context) (*raw.Client, error) {
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
return raw.NewClient(client, apiHost.rawURL), nil // closing over client
}
// Create default toolsets
tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator, cfg.ContentWindowSize)
err = tsg.EnableToolsets(enabledToolsets)
if err != nil {
return nil, fmt.Errorf("failed to enable toolsets: %w", err)
}
// Register all mcp functionality with the server
tsg.RegisterAll(ghServer)
if cfg.DynamicToolsets {
dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator)
dynamic.RegisterTools(ghServer)
}
return ghServer, nil
}
type StdioServerConfig struct {
// Version of the server
Version string
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
Host string
// GitHub Token to authenticate with the GitHub API
Token string
// EnabledToolsets is a list of toolsets to enable
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string
// Whether to enable dynamic toolsets
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
DynamicToolsets bool
// ReadOnly indicates if we should only register read-only tools
ReadOnly bool
// ExportTranslations indicates if we should export translations
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
ExportTranslations bool
// EnableCommandLogging indicates if we should log commands
EnableCommandLogging bool
// Path to the log file if not stderr
LogFilePath string
// Content window size
ContentWindowSize int
}
// RunStdioServer is not concurrent safe.
func RunStdioServer(cfg StdioServerConfig) error {
// Create app context
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
t, dumpTranslations := translations.TranslationHelper()
ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
stdioServer := server.NewStdioServer(ghServer)
var slogHandler slog.Handler
var logOutput io.Writer
if cfg.LogFilePath != "" {
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
logOutput = file
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
logOutput = os.Stderr
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
}
logger := slog.New(slogHandler)
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly)
stdLogger := log.New(logOutput, stdioServerLogPrefix, 0)
stdioServer.SetErrorLogger(stdLogger)
if cfg.ExportTranslations {
// Once server is initialized, all translations are loaded
dumpTranslations()
}
// Start listening for messages
errC := make(chan error, 1)
go func() {
in, out := io.Reader(os.Stdin), io.Writer(os.Stdout)
if cfg.EnableCommandLogging {
loggedIO := mcplog.NewIOLogger(in, out, logger)
in, out = loggedIO, loggedIO
}
// enable GitHub errors in the context
ctx := errors.ContextWithGitHubErrors(ctx)
errC <- stdioServer.Listen(ctx, in, out)
}()
// Output github-mcp-server string
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n")
// Wait for shutdown signal
select {
case <-ctx.Done():
logger.Info("shutting down server", "signal", "context done")
case err := <-errC:
if err != nil {
logger.Error("error running server", "error", err)
return fmt.Errorf("error running server: %w", err)
}
}
return nil
}
type apiHost struct {
baseRESTURL *url.URL
graphqlURL *url.URL
uploadURL *url.URL
rawURL *url.URL
}
func newDotcomHost() (apiHost, error) {
baseRestURL, err := url.Parse("https://api.github.com/")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
}
gqlURL, err := url.Parse("https://api.github.com/graphql")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
}
uploadURL, err := url.Parse("https://uploads.github.com")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
}
rawURL, err := url.Parse("https://raw.githubusercontent.com/")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
}
return apiHost{
baseRESTURL: baseRestURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}
func newGHECHost(hostname string) (apiHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
}
// Unsecured GHEC would be an error
if u.Scheme == "http" {
return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS")
}
restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
}
uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
}
rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
}
return apiHost{
baseRESTURL: restURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}
func newGHESHost(hostname string) (apiHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
}
restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
}
uploadURL, err := url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
}
rawURL, err := url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
}
return apiHost{
baseRESTURL: restURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}
// Note that this does not handle ports yet, so development environments are out.
func parseAPIHost(s string) (apiHost, error) {
if s == "" {
return newDotcomHost()
}
u, err := url.Parse(s)
if err != nil {
return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s)
}
if u.Scheme == "" {
return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
}
if strings.HasSuffix(u.Hostname(), "github.com") {
return newDotcomHost()
}
if strings.HasSuffix(u.Hostname(), "ghe.com") {
return newGHECHost(s)
}
return newGHESHost(s)
}
type userAgentTransport struct {
transport http.RoundTripper
agent string
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", t.agent)
return t.transport.RoundTrip(req)
}
type bearerAuthTransport struct {
transport http.RoundTripper
token string
}
func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+t.token)
return t.transport.RoundTrip(req)
}