Files
Adam Holt efe9d40b58
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
Token scopes context (#1997)
* Move scope storage into its own context key, separately from token info.

This allows us to provide scopes seperately in the remote server, where
we have scopes before we do the auth.

* Skip token extraction if token info already exists in context.

This is to avoid redundant token extraction in remote setup where token info may have already been extracted earlier in the request lifecycle.

* Check for existing scopes in context before fetching from GitHub API in scope challenge middleware

* Return error type for unknown tools in inventory builder and handle it in HTTP handler
2026-02-16 14:10:28 +01:00

306 lines
9.5 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)
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
}
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
func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
return func(r *http.Request) (*inventory.Inventory, error) {
b := github.NewInventory(t).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithFeatureChecker(featureChecker)
b = InventoryFiltersForRequest(r, b)
b = PATScopeFilter(b, r, scopeFetcher)
b.WithServerInstructions()
return b.Build()
}
}
// InventoryFiltersForRequest applies filters to the inventory builder
// based on the request context and headers
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(false, toolsets, tools)) // No dynamic toolsets in HTTP mode
}
if len(tools) > 0 {
if len(toolsets) == 0 {
builder = builder.WithToolsets([]string{})
}
builder = builder.WithTools(github.CleanTools(tools))
}
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
}