package ghmcp import ( "context" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/observability" "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // githubClients holds all the GitHub API clients created for a server instance. type githubClients struct { rest *gogithub.Client restUATransp *transport.UserAgentTransport gql *githubv4.Client gqlHTTP *http.Client // retained for middleware to modify transport raw *raw.Client repoAccess *lockdown.RepoAccessCache } // createGitHubClients creates all the GitHub API clients needed by the server. func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver) (*githubClients, error) { restURL, err := apiHost.BaseRESTURL(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get base REST URL: %w", err) } uploadURL, err := apiHost.UploadURL(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get upload URL: %w", err) } graphQLURL, err := apiHost.GraphqlURL(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get GraphQL URL: %w", err) } rawURL, err := apiHost.RawURL(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get Raw URL: %w", err) } // Construct REST client. When a TokenProvider is configured (OAuth), we // authenticate via BearerAuthTransport and skip go-github's WithAuthToken: // the latter installs its own round tripper that would pin the static token // and shadow the dynamic one. restUATransport := &transport.UserAgentTransport{ Transport: http.DefaultTransport, Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), } var restClient *gogithub.Client if cfg.TokenProvider != nil { restClient, err = gogithub.NewClient( gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{ Transport: restUATransport, TokenProvider: cfg.TokenProvider, }}), gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), ) } else { restClient, err = gogithub.NewClient( gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), gogithub.WithAuthToken(cfg.Token), gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), ) } if err != nil { return nil, fmt.Errorf("failed to create REST client: %w", err) } // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host gqlHTTPClient := &http.Client{ Transport: &transport.BearerAuthTransport{ Transport: &transport.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, Token: cfg.Token, TokenProvider: cfg.TokenProvider, }, } gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) rawClient, err := raw.NewClient(restClient, rawURL) if err != nil { return nil, fmt.Errorf("failed to create raw client: %w", err) } // Set up repo access cache for lockdown mode var repoAccessCache *lockdown.RepoAccessCache if cfg.LockdownMode { opts := []lockdown.RepoAccessOption{ lockdown.WithLogger(cfg.Logger.With("component", "lockdown")), } if cfg.RepoAccessTTL != nil { opts = append(opts, lockdown.WithTTL(*cfg.RepoAccessTTL)) } repoAccessCache = lockdown.NewRepoAccessCache(gqlClient, restClient, opts...) } return &githubClients{ rest: restClient, restUATransp: restUATransport, gql: gqlClient, gqlHTTP: gqlHTTPClient, raw: rawClient, repoAccess: repoAccessCache, }, nil } func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Server, error) { apiHost, err := utils.NewAPIHost(cfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } clients, err := createGitHubClients(cfg, apiHost) if err != nil { return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } // Create feature checker — resolves explicit features + insiders expansion featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) // Create dependencies for tool handlers obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) if err != nil { return nil, fmt.Errorf("failed to create observability exporters: %w", err) } deps := github.NewBaseDeps( clients.rest, clients.gql, clients.raw, clients.repoAccess, cfg.Translator, github.FeatureFlags{ LockdownMode: cfg.LockdownMode, }, cfg.ContentWindowSize, featureChecker, obs, ) // Build and register the tool/resource/prompt inventory inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)). WithTools(github.CleanTools(cfg.EnabledTools)). WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). WithFeatureChecker(featureChecker) // Apply token scope filtering if scopes are known (for PAT filtering) if cfg.TokenScopes != nil { inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) } inventory, err := inventoryBuilder.Build() if err != nil { return nil, fmt.Errorf("failed to build inventory: %w", err) } ghServer, err := github.NewMCPServer(ctx, &cfg, deps, inventory) if err != nil { return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err) } ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) 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 // EnabledTools is a list of specific tools to enable (additive to toolsets) // When specified, these tools are registered in addition to any specified toolset tools EnabledTools []string // EnabledFeatures is a list of feature flags that are enabled // Items with FeatureFlagEnable matching an entry in this list will be available EnabledFeatures []string // 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 // LockdownMode indicates if we should enable lockdown mode LockdownMode bool // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool // ExcludeTools is a list of tool names to disable regardless of other settings. // These tools will be excluded even if their toolset is enabled or they are // explicitly listed in EnabledTools. ExcludeTools []string // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration // OAuthManager, when non-nil, enables OAuth 2.1 login for stdio mode. The // server starts without a token and runs the authorization flow on the // first tool call (see createOAuthMiddleware). It is mutually exclusive with // a static Token. OAuthManager *oauth.Manager // OAuthScopes are the scopes requested during OAuth login. They double as // the scope set for tool filtering: tools requiring a scope outside this set // are hidden. The default set is the full supported list, which hides // nothing; an explicit, narrower list filters accordingly. OAuthScopes []string } // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { // OAuth login and a static token are mutually exclusive: they would // disagree on how the token is sourced (lazy provider vs. static) and on // scope filtering, so reject the ambiguous combination up front. if cfg.OAuthManager != nil && cfg.Token != "" { return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") } // Create app context ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() t, dumpTranslations := translations.TranslationHelper() 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, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) // Determine the scope set used to filter tools. Classic PATs expose their // granted scopes via the API; OAuth uses the requested scopes (the default // set hides nothing, a narrower explicit set filters accordingly). Other // token types don't advertise scopes, so filtering is skipped. var tokenScopes []string switch { case strings.HasPrefix(cfg.Token, "ghp_"): fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) if err != nil { logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) } else { tokenScopes = fetchedScopes logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } case cfg.OAuthManager != nil: tokenScopes = cfg.OAuthScopes logger.Info("using requested OAuth scopes for tool filtering", "scopes", tokenScopes) default: logger.Debug("skipping scope filtering for non-PAT token") } // For OAuth, the token is resolved lazily: empty until the user authorizes // on the first tool call, then refreshed for the rest of the session. var tokenProvider func() string if cfg.OAuthManager != nil { tokenProvider = cfg.OAuthManager.AccessToken } ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, ReadOnly: cfg.ReadOnly, Translator: t, ContentWindowSize: cfg.ContentWindowSize, LockdownMode: cfg.LockdownMode, InsidersMode: cfg.InsidersMode, ExcludeTools: cfg.ExcludeTools, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, TokenProvider: tokenProvider, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } // With OAuth, intercept tool calls to run the authorization flow on first // use, before the handler tries to call GitHub with an empty token. if cfg.OAuthManager != nil { ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger)) } if cfg.ExportTranslations { // Once server is initialized, all translations are loaded dumpTranslations() } // Start listening for messages errC := make(chan error, 1) go func() { var in io.ReadCloser var out io.WriteCloser in = os.Stdin out = 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 <- ghServer.Run(ctx, &mcp.IOTransport{Reader: in, Writer: 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 } // createFeatureChecker returns a FeatureFlagChecker that resolves features // using the centralized ResolveFeatureFlags function. For the local server, // features are resolved once at startup from --features CLI flag and insiders mode. func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker { featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode) return func(_ context.Context, flagName string) (bool, error) { return featureSet[flagName], nil } } func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { if method != "initialize" { return next(ctx, method, request) } initializeRequest, ok := request.(*mcp.InitializeRequest) if !ok { return next(ctx, method, request) } message := initializeRequest userAgent := fmt.Sprintf( "github-mcp-server/%s (%s/%s)", cfg.Version, message.Params.ClientInfo.Name, message.Params.ClientInfo.Version, ) if cfg.InsidersMode { userAgent += " (insiders)" } restUATransp.Agent = userAgent gqlHTTPClient.Transport = &transport.UserAgentTransport{ Transport: gqlHTTPClient.Transport, Agent: userAgent, } return next(ctx, method, request) } } } // fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. // It constructs the appropriate API host URL based on the configured host. func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { apiHost, err := utils.NewAPIHost(host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } fetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{}) return fetcher.FetchTokenScopes(ctx, token) }