b3ecab4a01
Bounds the total HTTP request, so allow modest headroom over the MCP SDK's 4 MiB default for JSON-RPC and tool-call envelope overhead rather than spending the whole budget on tool content. Because the limit now exceeds the SDK default, passing it to StreamableHTTPOptions is load-bearing: without it the SDK would cap requests at 4 MiB and the headroom would not exist. Covered by a test that sends a request between the two limits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
489 lines
17 KiB
Go
489 lines
17 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
|
|
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/headers"
|
|
"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/jsonrpc"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
const subscriptionsListenMethod = "subscriptions/listen"
|
|
|
|
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(
|
|
// Must run first: bounds the body before anything downstream reads it.
|
|
middleware.WithMaxBodySize(h.maxRequestBodyBytes()),
|
|
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))
|
|
}
|
|
}
|
|
|
|
// maxRequestBodyBytes returns the effective request-body size limit, applied
|
|
// both by the early middleware and by the MCP SDK handler.
|
|
func (h *Handler) maxRequestBodyBytes() int64 {
|
|
if h.config != nil && h.config.MaxRequestBodyBytes > 0 {
|
|
return h.config.MaxRequestBodyBytes
|
|
}
|
|
return middleware.DefaultMaxRequestBodyBytes
|
|
}
|
|
|
|
// 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,
|
|
// Capabilities (no list-changed advertising) are set by NewMCPServer;
|
|
// here we only supply the remote-specific schema cache.
|
|
ServerOptions: []github.MCPServerOption{
|
|
func(so *mcp.ServerOptions) {
|
|
so.SchemaCache = h.schemaCache
|
|
},
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Let the SDK validate missing or mismatched method headers before the
|
|
// middleware rejects a well-formed request as unsupported.
|
|
if r.Header.Get(headers.MCPMethodHeader) == subscriptionsListenMethod {
|
|
ghServer.AddReceivingMiddleware(rejectSubscriptionsListen)
|
|
}
|
|
|
|
// 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,
|
|
// Required, not just belt-and-braces: the effective limit exceeds the
|
|
// SDK's own default, which would otherwise cap it.
|
|
MaxRequestBodyBytes: h.maxRequestBodyBytes(),
|
|
})
|
|
|
|
mcpHandler.ServeHTTP(w, r)
|
|
}
|
|
|
|
func rejectSubscriptionsListen(next mcp.MethodHandler) mcp.MethodHandler {
|
|
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
|
|
if method == subscriptionsListenMethod {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeMethodNotFound,
|
|
Message: "method not found",
|
|
}
|
|
}
|
|
return next(ctx, method, req)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
factory, err := NewDefaultInventoryFactory(cfg, t, featureChecker, scopeFetcher)
|
|
if err != nil {
|
|
return func(_ *http.Request) (*inventory.Inventory, error) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return factory
|
|
}
|
|
|
|
// NewDefaultInventoryFactory creates the default HTTP inventory factory and
|
|
// validates static tool configuration before returning it.
|
|
func NewDefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) (InventoryFactoryFunc, error) {
|
|
// Build the static tool/resource/prompt universe from CLI flags.
|
|
// This is done once at startup and captured in the closure.
|
|
staticTools, staticResources, staticPrompts, err := buildStaticInventory(cfg, t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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()
|
|
}, nil
|
|
}
|
|
|
|
// 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, error) {
|
|
// Tools with host-specific capabilities need to know the deployment they
|
|
// will talk to. An unparseable host is not fatal here: NewAPIHost rejects
|
|
// it later with a clearer error, so fall back to the dotcom default.
|
|
hostType, err := utils.ParseHostType(cfg.Host)
|
|
if err != nil {
|
|
hostType = utils.HostTypeDotcom
|
|
}
|
|
opts := []github.ToolOption{github.WithHost(hostType)}
|
|
tools := github.AllTools(t, opts...)
|
|
filterUnavailable := func(tools []inventory.ServerTool) []inventory.ServerTool {
|
|
if !cfg.disableDeleteRepository {
|
|
return tools
|
|
}
|
|
return slices.DeleteFunc(tools, func(tool inventory.ServerTool) bool {
|
|
return tool.Tool.Name == github.DeleteRepositoryToolName
|
|
})
|
|
}
|
|
|
|
if !hasStaticConfig(cfg) {
|
|
return filterUnavailable(tools), github.AllResources(t), github.AllPrompts(t), nil
|
|
}
|
|
|
|
b := inventory.NewBuilder().
|
|
SetTools(tools).
|
|
SetResources(github.AllResources(t)).
|
|
SetPrompts(github.AllPrompts(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 {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
return filterUnavailable(inv.AvailableTools(ctx)), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx), nil
|
|
}
|
|
|
|
// 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
|
|
}
|