Skip to content

Commit d336e5b

Browse files
omgitsadsmattdhollowayCopilot
authored
Scope challenge http (#1925)
* initial oauth metadata implementation * add nolint for GetEffectiveHostAndScheme * remove CAPI reference * remove nonsensical example URL * anonymize * add oauth tests * replace custom protected resource metadata handler with our own * remove unused header * Update pkg/http/oauth/oauth.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * pass oauth config to mcp handler for token extraction * chore: retrigger ci * align types with base branch * update more types * initial oauth metadata implementation * add nolint for GetEffectiveHostAndScheme * remove CAPI reference * remove nonsensical example URL * anonymize * add oauth tests * replace custom protected resource metadata handler with our own * Update pkg/http/oauth/oauth.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: retrigger ci * update more types * remove CAPI specific header * restore mcp path specific logic * WIP * implement better resource path handling for OAuth server * return auth handler to lib version * rename to base-path flag * Add scopes challenge middleware to HTTP handler and initialize global tool scope map * Flags on the http command * Add tests for scope maps * Add scope challenge & pat filtering based on token scopes * Add scope filtering if challenge is enabled * Linter fixes and renamed scope challenge to PAT scope filter * Linter issues. * Remove unsused methoodod FetchScopesFromGitHubAPI, store active scopes in context * Require an API host when creating scope fetchers * Add tests for MCP parsing middleware * Remove IDE token support, these will never work. Add tests. * Move ForMCPRequest call to HTTP handler & explicitly set capabilities --------- Co-authored-by: Matt Holloway <mattdholloway@pm.me> Co-authored-by: Matt Holloway <mattdholloway@github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 206f7f3 commit d336e5b

File tree

20 files changed

+1465
-135
lines changed

20 files changed

+1465
-135
lines changed

cmd/github-mcp-server/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ var (
109109
ContentWindowSize: viper.GetInt("content-window-size"),
110110
LockdownMode: viper.GetBool("lockdown-mode"),
111111
RepoAccessCacheTTL: &ttl,
112+
ScopeChallenge: viper.GetBool("scope-challenge"),
112113
}
113114

114115
return ghhttp.RunHTTPServer(httpConfig)
@@ -141,6 +142,7 @@ func init() {
141142
httpCmd.Flags().Int("port", 8082, "HTTP server port")
142143
httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
143144
httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")
145+
httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses and tool filtering based on token scopes")
144146

145147
// Bind flag to viper
146148
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -159,7 +161,7 @@ func init() {
159161
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
160162
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
161163
_ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))
162-
164+
_ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
163165
// Add subcommands
164166
rootCmd.AddCommand(stdioCmd)
165167
rootCmd.AddCommand(httpCmd)

internal/ghmcp/server.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -366,14 +366,7 @@ func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string,
366366
return nil, fmt.Errorf("failed to parse API host: %w", err)
367367
}
368368

369-
baseRestURL, err := apiHost.BaseRESTURL(ctx)
370-
if err != nil {
371-
return nil, fmt.Errorf("failed to get base REST URL: %w", err)
372-
}
373-
374-
fetcher := scopes.NewFetcher(scopes.FetcherOptions{
375-
APIHost: baseRestURL.String(),
376-
})
369+
fetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
377370

378371
return fetcher.FetchTokenScopes(ctx, token)
379372
}

pkg/context/mcp_info.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package context
2+
3+
import "context"
4+
5+
type mcpMethodInfoCtx string
6+
7+
var mcpMethodInfoCtxKey mcpMethodInfoCtx = "mcpmethodinfo"
8+
9+
// MCPMethodInfo contains pre-parsed MCP method information extracted from the JSON-RPC request.
10+
// This is populated early in the request lifecycle to enable:
11+
// - Inventory filtering via ForMCPRequest (only register needed tools/resources/prompts)
12+
// - Avoiding duplicate JSON parsing in middlewares (secret-scanning, scope-challenge)
13+
// - Performance optimization for per-request server creation
14+
type MCPMethodInfo struct {
15+
// Method is the MCP method being called (e.g., "tools/call", "tools/list", "initialize")
16+
Method string
17+
// ItemName is the name of the specific item being accessed (tool name, resource URI, prompt name)
18+
// Only populated for call/get methods (tools/call, prompts/get, resources/read)
19+
ItemName string
20+
// Owner is the repository owner from tool call arguments, if present
21+
Owner string
22+
// Repo is the repository name from tool call arguments, if present
23+
Repo string
24+
// Arguments contains the raw tool arguments for tools/call requests
25+
Arguments map[string]any
26+
}
27+
28+
// WithMCPMethodInfo stores the MCPMethodInfo in the context.
29+
func WithMCPMethodInfo(ctx context.Context, info *MCPMethodInfo) context.Context {
30+
return context.WithValue(ctx, mcpMethodInfoCtxKey, info)
31+
}
32+
33+
// MCPMethod retrieves the MCPMethodInfo from the context.
34+
func MCPMethod(ctx context.Context) (*MCPMethodInfo, bool) {
35+
if info, ok := ctx.Value(mcpMethodInfoCtxKey).(*MCPMethodInfo); ok {
36+
return info, true
37+
}
38+
return nil, false
39+
}

pkg/context/token.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,39 @@
11
package context
22

3-
import "context"
3+
import (
4+
"context"
5+
6+
"github.com/github/github-mcp-server/pkg/utils"
7+
)
48

59
// tokenCtxKey is a context key for authentication token information
6-
type tokenCtxKey struct{}
10+
type tokenCtx string
11+
12+
var tokenCtxKey tokenCtx = "tokenctx"
13+
14+
type TokenInfo struct {
15+
Token string
16+
TokenType utils.TokenType
17+
ScopesFetched bool
18+
Scopes []string
19+
}
720

821
// WithTokenInfo adds TokenInfo to the context
9-
func WithTokenInfo(ctx context.Context, token string) context.Context {
10-
return context.WithValue(ctx, tokenCtxKey{}, token)
22+
func WithTokenInfo(ctx context.Context, tokenInfo *TokenInfo) context.Context {
23+
return context.WithValue(ctx, tokenCtxKey, tokenInfo)
24+
}
25+
26+
func SetTokenScopes(ctx context.Context, scopes []string) {
27+
if tokenInfo, ok := GetTokenInfo(ctx); ok {
28+
tokenInfo.Scopes = scopes
29+
tokenInfo.ScopesFetched = true
30+
}
1131
}
1232

1333
// GetTokenInfo retrieves the authentication token from the context
14-
func GetTokenInfo(ctx context.Context) (string, bool) {
15-
if token, ok := ctx.Value(tokenCtxKey{}).(string); ok {
16-
return token, true
34+
func GetTokenInfo(ctx context.Context) (*TokenInfo, bool) {
35+
if tokenInfo, ok := ctx.Value(tokenCtxKey).(*TokenInfo); ok {
36+
return tokenInfo, true
1737
}
18-
return "", false
38+
return nil, false
1939
}

pkg/github/dependencies.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,11 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
282282
}
283283

284284
// extract the token from the context
285-
token, _ := ghcontext.GetTokenInfo(ctx)
285+
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
286+
if !ok {
287+
return nil, fmt.Errorf("no token info in context")
288+
}
289+
token := tokenInfo.Token
286290

287291
baseRestURL, err := d.apiHosts.BaseRESTURL(ctx)
288292
if err != nil {
@@ -308,7 +312,11 @@ func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error
308312
}
309313

310314
// extract the token from the context
311-
token, _ := ghcontext.GetTokenInfo(ctx)
315+
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
316+
if !ok {
317+
return nil, fmt.Errorf("no token info in context")
318+
}
319+
token := tokenInfo.Token
312320

313321
// Construct GraphQL client
314322
// We use NewEnterpriseClient unconditionally since we already parsed the API host

pkg/github/server.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ type MCPServerConfig struct {
7373

7474
type MCPServerOption func(*mcp.ServerOptions)
7575

76-
func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inventory *inventory.Inventory) (*mcp.Server, error) {
76+
func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory) (*mcp.Server, error) {
7777
// Create the MCP server
7878
serverOpts := &mcp.ServerOptions{
79-
Instructions: inventory.Instructions(),
79+
Instructions: inv.Instructions(),
8080
Logger: cfg.Logger,
8181
CompletionHandler: CompletionsHandler(deps.GetClient),
8282
}
@@ -102,20 +102,20 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci
102102
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
103103
ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps))
104104

105-
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
105+
if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 {
106106
cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", "))
107107
}
108108

109109
// Register GitHub tools/resources/prompts from the inventory.
110110
// In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
111111
// is empty - users enable toolsets at runtime via the dynamic tools below (but can
112112
// enable toolsets or tools explicitly that do need registration).
113-
inventory.RegisterAll(ctx, ghServer, deps)
113+
inv.RegisterAll(ctx, ghServer, deps)
114114

115115
// Register dynamic toolset management tools (enable/disable) - these are separate
116116
// meta-tools that control the inventory, not part of the inventory itself
117117
if cfg.DynamicToolsets {
118-
registerDynamicTools(ghServer, inventory, deps, cfg.Translator)
118+
registerDynamicTools(ghServer, inv, deps, cfg.Translator)
119119
}
120120

121121
return ghServer, nil

pkg/http/handler.go

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"github.com/github/github-mcp-server/pkg/http/middleware"
1111
"github.com/github/github-mcp-server/pkg/http/oauth"
1212
"github.com/github/github-mcp-server/pkg/inventory"
13+
"github.com/github/github-mcp-server/pkg/scopes"
1314
"github.com/github/github-mcp-server/pkg/translations"
15+
"github.com/github/github-mcp-server/pkg/utils"
1416
"github.com/go-chi/chi/v5"
1517
"github.com/modelcontextprotocol/go-sdk/mcp"
1618
)
@@ -23,21 +25,30 @@ type Handler struct {
2325
config *ServerConfig
2426
deps github.ToolDependencies
2527
logger *slog.Logger
28+
apiHosts utils.APIHostResolver
2629
t translations.TranslationHelperFunc
2730
githubMcpServerFactory GitHubMCPServerFactoryFunc
2831
inventoryFactoryFunc InventoryFactoryFunc
2932
oauthCfg *oauth.Config
33+
scopeFetcher scopes.FetcherInterface
3034
}
3135

3236
type HandlerOptions struct {
3337
GitHubMcpServerFactory GitHubMCPServerFactoryFunc
3438
InventoryFactory InventoryFactoryFunc
3539
OAuthConfig *oauth.Config
40+
ScopeFetcher scopes.FetcherInterface
3641
FeatureChecker inventory.FeatureFlagChecker
3742
}
3843

3944
type HandlerOption func(*HandlerOptions)
4045

46+
func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption {
47+
return func(o *HandlerOptions) {
48+
o.ScopeFetcher = f
49+
}
50+
}
51+
4152
func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption {
4253
return func(o *HandlerOptions) {
4354
o.GitHubMcpServerFactory = f
@@ -68,6 +79,7 @@ func NewHTTPMcpHandler(
6879
deps github.ToolDependencies,
6980
t translations.TranslationHelperFunc,
7081
logger *slog.Logger,
82+
apiHost utils.APIHostResolver,
7183
options ...HandlerOption) *Handler {
7284
opts := &HandlerOptions{}
7385
for _, o := range options {
@@ -79,28 +91,40 @@ func NewHTTPMcpHandler(
7991
githubMcpServerFactory = DefaultGitHubMCPServerFactory
8092
}
8193

94+
scopeFetcher := opts.ScopeFetcher
95+
if scopeFetcher == nil {
96+
scopeFetcher = scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
97+
}
98+
8299
inventoryFactory := opts.InventoryFactory
83100
if inventoryFactory == nil {
84-
inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker)
101+
inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher)
85102
}
86103

87104
return &Handler{
88105
ctx: ctx,
89106
config: cfg,
90107
deps: deps,
91108
logger: logger,
109+
apiHosts: apiHost,
92110
t: t,
93111
githubMcpServerFactory: githubMcpServerFactory,
94112
inventoryFactoryFunc: inventoryFactory,
95113
oauthCfg: opts.OAuthConfig,
114+
scopeFetcher: scopeFetcher,
96115
}
97116
}
98117

99118
func (h *Handler) RegisterMiddleware(r chi.Router) {
100119
r.Use(
101120
middleware.ExtractUserToken(h.oauthCfg),
102121
middleware.WithRequestConfig,
122+
middleware.WithMCPParse(),
103123
)
124+
125+
if h.config.ScopeChallenge {
126+
r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher))
127+
}
104128
}
105129

106130
// RegisterRoutes registers the routes for the MCP server
@@ -145,22 +169,38 @@ func withInsiders(next http.Handler) http.Handler {
145169
}
146170

147171
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148-
inventory, err := h.inventoryFactoryFunc(r)
172+
inv, err := h.inventoryFactoryFunc(r)
149173
if err != nil {
150174
w.WriteHeader(http.StatusInternalServerError)
151175
return
152176
}
153177

154-
ghServer, err := h.githubMcpServerFactory(r, h.deps, inventory, &github.MCPServerConfig{
178+
invToUse := inv
179+
if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil {
180+
invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName)
181+
}
182+
183+
ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{
155184
Version: h.config.Version,
156185
Translator: h.t,
157186
ContentWindowSize: h.config.ContentWindowSize,
158187
Logger: h.logger,
159188
RepoAccessTTL: h.config.RepoAccessCacheTTL,
189+
// Explicitly set empty capabilities. inv.ForMCPRequest currently returns nothing for Initialize.
190+
ServerOptions: []github.MCPServerOption{
191+
func(so *mcp.ServerOptions) {
192+
so.Capabilities = &mcp.ServerCapabilities{
193+
Tools: &mcp.ToolCapabilities{},
194+
Resources: &mcp.ResourceCapabilities{},
195+
Prompts: &mcp.PromptCapabilities{},
196+
}
197+
},
198+
},
160199
})
161200

162201
if err != nil {
163202
w.WriteHeader(http.StatusInternalServerError)
203+
return
164204
}
165205

166206
mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
@@ -177,13 +217,15 @@ func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies
177217
}
178218

179219
// DefaultInventoryFactory creates the default inventory factory for HTTP mode
180-
func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker) InventoryFactoryFunc {
220+
func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
181221
return func(r *http.Request) (*inventory.Inventory, error) {
182222
b := github.NewInventory(t).
183223
WithDeprecatedAliases(github.DeprecatedToolAliases).
184224
WithFeatureChecker(featureChecker)
185225

186226
b = InventoryFiltersForRequest(r, b)
227+
b = PATScopeFilter(b, r, scopeFetcher)
228+
187229
b.WithServerInstructions()
188230

189231
return b.Build()
@@ -215,3 +257,29 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in
215257

216258
return builder
217259
}
260+
261+
func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder {
262+
ctx := r.Context()
263+
264+
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
265+
if !ok || tokenInfo == nil {
266+
return b
267+
}
268+
269+
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
270+
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
271+
// Fine-grained PATs and other token types don't support this, so we skip filtering.
272+
if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
273+
scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token)
274+
if err != nil {
275+
return b
276+
}
277+
278+
// Store fetched scopes in context for downstream use
279+
ghcontext.SetTokenScopes(ctx, scopesList)
280+
281+
return b.WithFilter(github.CreateToolScopeFilter(scopesList))
282+
}
283+
284+
return b
285+
}

0 commit comments

Comments
 (0)