6e0af328bc
Bumps github.com/modelcontextprotocol/go-sdk from v1.6.0 to v1.6.1 and removes the CrossOriginProtection bypass we previously installed on the StreamableHTTP handler. As of go-sdk v1.6.0 the cross-origin check is opt-in: a nil CrossOriginProtection on StreamableHTTPOptions means no check is run. v1.6.1 also marks the field itself as deprecated (the SDK recommends wrapping the handler with middleware instead, and the field will be removed in v1.8.0). This server authenticates via bearer tokens, not cookies, so the Sec-Fetch-Site CSRF check is unnecessary and would block browser-based MCP clients. Leaving CrossOriginProtection unset preserves that behavior without depending on a deprecated API. Supersedes #2541. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
422 lines
14 KiB
Go
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
|
|
}
|