Files
github--github-mcp-server/pkg/http/handler.go
T
2026-07-13 12:37:57 +08:00

422 lines
14 KiB
Go

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
}