package http import ( "context" "errors" "log/slog" "net/http" ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/middleware" "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/mcp" ) type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error) // GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance. // middleware are applied AFTER the default GitHub MCP Server middlewares (like error context injection) type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) type Handler struct { ctx context.Context config *ServerConfig deps github.ToolDependencies logger *slog.Logger apiHosts utils.APIHostResolver t translations.TranslationHelperFunc githubMcpServerFactory GitHubMCPServerFactoryFunc inventoryFactoryFunc InventoryFactoryFunc oauthCfg *oauth.Config scopeFetcher scopes.FetcherInterface schemaCache *mcp.SchemaCache } type HandlerOptions struct { GitHubMcpServerFactory GitHubMCPServerFactoryFunc InventoryFactory InventoryFactoryFunc OAuthConfig *oauth.Config ScopeFetcher scopes.FetcherInterface FeatureChecker inventory.FeatureFlagChecker } type HandlerOption func(*HandlerOptions) func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption { return func(o *HandlerOptions) { o.ScopeFetcher = f } } func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption { return func(o *HandlerOptions) { o.GitHubMcpServerFactory = f } } func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption { return func(o *HandlerOptions) { o.InventoryFactory = f } } func WithOAuthConfig(cfg *oauth.Config) HandlerOption { return func(o *HandlerOptions) { o.OAuthConfig = cfg } } func WithFeatureChecker(checker inventory.FeatureFlagChecker) HandlerOption { return func(o *HandlerOptions) { o.FeatureChecker = checker } } func NewHTTPMcpHandler( ctx context.Context, cfg *ServerConfig, deps github.ToolDependencies, t translations.TranslationHelperFunc, logger *slog.Logger, apiHost utils.APIHostResolver, options ...HandlerOption) *Handler { opts := &HandlerOptions{} for _, o := range options { o(opts) } githubMcpServerFactory := opts.GitHubMcpServerFactory if githubMcpServerFactory == nil { githubMcpServerFactory = DefaultGitHubMCPServerFactory } scopeFetcher := opts.ScopeFetcher if scopeFetcher == nil { scopeFetcher = scopes.NewFetcher(apiHost, scopes.FetcherOptions{}) } inventoryFactory := opts.InventoryFactory if inventoryFactory == nil { inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher) } // Create a shared schema cache to avoid repeated JSON schema reflection // when a new MCP Server is created per request in stateless mode. schemaCache := mcp.NewSchemaCache() return &Handler{ ctx: ctx, config: cfg, deps: deps, logger: logger, apiHosts: apiHost, t: t, githubMcpServerFactory: githubMcpServerFactory, inventoryFactoryFunc: inventoryFactory, oauthCfg: opts.OAuthConfig, scopeFetcher: scopeFetcher, schemaCache: schemaCache, } } func (h *Handler) RegisterMiddleware(r chi.Router) { r.Use( middleware.ExtractUserToken(h.oauthCfg), middleware.WithRequestConfig, middleware.WithMCPParse(), middleware.WithPATScopes(h.logger, h.scopeFetcher), ) if h.config.ScopeChallenge { r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher)) } } // RegisterRoutes registers the routes for the MCP server // URL-based values take precedence over header-based values func (h *Handler) RegisterRoutes(r chi.Router) { // Base routes r.Mount("/", h) r.With(withReadonly).Mount("/readonly", h) r.With(withInsiders).Mount("/insiders", h) r.With(withReadonly, withInsiders).Mount("/readonly/insiders", h) // Toolset routes r.With(withToolset).Mount("/x/{toolset}", h) r.With(withToolset, withReadonly).Mount("/x/{toolset}/readonly", h) r.With(withToolset, withInsiders).Mount("/x/{toolset}/insiders", h) r.With(withToolset, withReadonly, withInsiders).Mount("/x/{toolset}/readonly/insiders", h) } // withReadonly is middleware that sets readonly mode in the request context func withReadonly(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := ghcontext.WithReadonly(r.Context(), true) next.ServeHTTP(w, r.WithContext(ctx)) }) } // withToolset is middleware that extracts the toolset from the URL and sets it in the request context func withToolset(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { toolset := chi.URLParam(r, "toolset") ctx := ghcontext.WithToolsets(r.Context(), []string{toolset}) next.ServeHTTP(w, r.WithContext(ctx)) }) } // withInsiders is middleware that sets insiders mode in the request context func withInsiders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := ghcontext.WithInsidersMode(r.Context(), true) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { inv, err := h.inventoryFactoryFunc(r) if err != nil { if errors.Is(err, inventory.ErrUnknownTools) { w.WriteHeader(http.StatusBadRequest) if _, writeErr := w.Write([]byte(err.Error())); writeErr != nil { h.logger.Error("failed to write response", "error", writeErr) } return } w.WriteHeader(http.StatusInternalServerError) return } invToUse := inv if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil { invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName) } ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ Version: h.config.Version, Translator: h.t, ContentWindowSize: h.config.ContentWindowSize, Logger: h.logger, RepoAccessTTL: h.config.RepoAccessCacheTTL, // Explicitly set empty capabilities. inv.ForMCPRequest currently returns nothing for Initialize. ServerOptions: []github.MCPServerOption{ func(so *mcp.ServerOptions) { so.Capabilities = &mcp.ServerCapabilities{ Tools: &mcp.ToolCapabilities{}, Resources: &mcp.ResourceCapabilities{}, Prompts: &mcp.PromptCapabilities{}, } so.SchemaCache = h.schemaCache }, }, }) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } // Cross-origin protection is intentionally left unset: this server // authenticates via bearer tokens (not cookies), so Sec-Fetch-Site CSRF // checks are unnecessary and would block browser-based MCP clients. As of // go-sdk v1.6.0 a nil CrossOriginProtection disables the check by default; // see also PR #2359. mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { return ghServer }, &mcp.StreamableHTTPOptions{ Stateless: true, }) mcpHandler.ServeHTTP(w, r) } func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) { return github.NewMCPServer(r.Context(), cfg, deps, inventory) } // DefaultInventoryFactory creates the default inventory factory for HTTP mode. // When the ServerConfig includes static flags (--toolsets, --read-only, etc.), // a static inventory is built once at factory creation to pre-filter the tool // universe. Per-request headers can only narrow within these bounds. func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc { // Build the static tool/resource/prompt universe from CLI flags. // This is done once at startup and captured in the closure. staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t) hasStaticFilters := hasStaticConfig(cfg) // Pre-compute valid tool names for filtering per-request tool headers. // When a request asks for a tool by name that's been excluded from the // static universe, we silently drop it rather than returning an error. validToolNames := make(map[string]bool, len(staticTools)) for i := range staticTools { validToolNames[staticTools[i].Tool.Name] = true } return func(r *http.Request) (*inventory.Inventory, error) { b := inventory.NewBuilder(). SetTools(staticTools). SetResources(staticResources). SetPrompts(staticPrompts). WithDeprecatedAliases(github.DeprecatedToolAliases). WithFeatureChecker(featureChecker) // When static flags constrain the universe, default to showing // everything within those bounds (per-request filters narrow further). // When no static flags are set, preserve existing behavior where // the default toolsets apply. if hasStaticFilters { b = b.WithToolsets([]string{"all"}) } // Static read-only is an upper bound — enforce before request filters if cfg.ReadOnly { b = b.WithReadOnly(true) } // Filter request tool names to only those in the static universe, // so requests for statically-excluded tools degrade gracefully. if hasStaticFilters { r = filterRequestTools(r, validToolNames) } b = InventoryFiltersForRequest(r, b) b = PATScopeFilter(b, r, scopeFetcher) b.WithServerInstructions() return b.Build() } } // filterRequestTools returns a shallow copy of the request with any per-request // tool names (from X-MCP-Tools header) filtered to only include tools that exist // in validNames. This ensures requests for statically-excluded tools are silently // ignored rather than causing build errors. func filterRequestTools(r *http.Request, validNames map[string]bool) *http.Request { reqTools := ghcontext.GetTools(r.Context()) if len(reqTools) == 0 { return r } filtered := make([]string, 0, len(reqTools)) for _, name := range reqTools { if validNames[name] { filtered = append(filtered, name) } } ctx := ghcontext.WithTools(r.Context(), filtered) return r.WithContext(ctx) } // hasStaticConfig returns true if any static filtering flags are set on the ServerConfig. func hasStaticConfig(cfg *ServerConfig) bool { return cfg.ReadOnly || cfg.EnabledToolsets != nil || cfg.EnabledTools != nil || len(cfg.ExcludeTools) > 0 } // buildStaticInventory pre-filters the full tool/resource/prompt universe using // the static config (toolsets, read-only, --tools, --exclude-tools). It does // NOT install a feature checker: HTTP feature flags can come from per-request // context (/insiders, X-MCP-Features), so dual-name feature variants — for // example the granular issues/PRs tools that share a name with their // non-granular siblings — must be carried through to the per-request // inventory, which then installs a checker and resolves the flag before // registering tools with the MCP server. func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { if !hasStaticConfig(cfg) { return github.AllTools(t), github.AllResources(t), github.AllPrompts(t) } b := github.NewInventory(t). WithReadOnly(cfg.ReadOnly). WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) if len(cfg.EnabledTools) > 0 { b = b.WithTools(github.CleanTools(cfg.EnabledTools)) } if len(cfg.ExcludeTools) > 0 { b = b.WithExcludeTools(cfg.ExcludeTools) } inv, err := b.Build() if err != nil { // Fall back to all tools if there's an error (e.g. unknown tool names). // The error will surface again at per-request time if relevant. return github.AllTools(t), github.AllResources(t), github.AllPrompts(t) } ctx := context.Background() return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx) } // InventoryFiltersForRequest applies filters to the inventory builder // based on the request context and headers. // MCP Apps UI metadata is handled by the builder via the feature checker — // no need to check headers here. func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder { ctx := r.Context() if ghcontext.IsReadonly(ctx) { builder = builder.WithReadOnly(true) } toolsets := ghcontext.GetToolsets(ctx) tools := ghcontext.GetTools(ctx) if len(toolsets) > 0 { builder = builder.WithToolsets(github.ResolvedEnabledToolsets(toolsets, tools)) } if len(tools) > 0 { if len(toolsets) == 0 { builder = builder.WithToolsets([]string{}) } builder = builder.WithTools(github.CleanTools(tools)) } if excluded := ghcontext.GetExcludeTools(ctx); len(excluded) > 0 { builder = builder.WithExcludeTools(excluded) } return builder } func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder { ctx := r.Context() tokenInfo, ok := ghcontext.GetTokenInfo(ctx) if !ok || tokenInfo == nil { return b } // Scopes should have already been fetched by the WithPATScopes middleware. // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. // Fine-grained PATs and other token types don't support this, so we skip filtering. if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken { // Check if scopes are already in context (should be set by WithPATScopes). If not, fetch them. existingScopes, ok := ghcontext.GetTokenScopes(ctx) if ok { return b.WithFilter(github.CreateToolScopeFilter(existingScopes)) } scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token) if err != nil { return b } return b.WithFilter(github.CreateToolScopeFilter(scopesList)) } return b }