Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
370ebca
initial oauth metadata implementation
mattdholloway Jan 22, 2026
0a1b701
add nolint for GetEffectiveHostAndScheme
mattdholloway Jan 22, 2026
afda19b
remove CAPI reference
mattdholloway Jan 23, 2026
97859a1
remove nonsensical example URL
mattdholloway Jan 23, 2026
f8f109c
anonymize
mattdholloway Jan 23, 2026
9f308b3
add oauth tests
mattdholloway Jan 23, 2026
9b5c2fb
Merge branch 'http-stack-2' into oauth-handler-implementation
mattdholloway Jan 23, 2026
50227bf
replace custom protected resource metadata handler with our own
mattdholloway Jan 26, 2026
a3135d9
remove unused header
mattdholloway Jan 26, 2026
1ce01df
Update pkg/http/oauth/oauth.go
mattdholloway Jan 26, 2026
4fc6c3a
pass oauth config to mcp handler for token extraction
mattdholloway Jan 28, 2026
b0bddbf
chore: retrigger ci
mattdholloway Jan 28, 2026
6c5102a
align types with base branch
mattdholloway Jan 28, 2026
3daa5c3
update more types
mattdholloway Jan 28, 2026
e3c565a
initial oauth metadata implementation
mattdholloway Jan 22, 2026
f768eda
add nolint for GetEffectiveHostAndScheme
mattdholloway Jan 22, 2026
68e1f50
remove CAPI reference
mattdholloway Jan 23, 2026
67b821c
remove nonsensical example URL
mattdholloway Jan 23, 2026
7c90050
anonymize
mattdholloway Jan 23, 2026
78f1a82
add oauth tests
mattdholloway Jan 23, 2026
e2699c8
replace custom protected resource metadata handler with our own
mattdholloway Jan 26, 2026
9c21eed
Update pkg/http/oauth/oauth.go
mattdholloway Jan 26, 2026
49191a9
chore: retrigger ci
mattdholloway Jan 28, 2026
03a5082
update more types
mattdholloway Jan 28, 2026
37c32c5
Merge branch 'oauth-handler-implementation' of https://github.com/git…
mattdholloway Jan 28, 2026
97092a0
remove CAPI specific header
mattdholloway Jan 28, 2026
cfea762
restore mcp path specific logic
mattdholloway Jan 28, 2026
199e62c
WIP
omgitsads Jan 29, 2026
840b41e
implement better resource path handling for OAuth server
mattdholloway Jan 29, 2026
203ebb3
return auth handler to lib version
mattdholloway Jan 29, 2026
3990325
rename to base-path flag
mattdholloway Jan 29, 2026
4b690f5
Add scopes challenge middleware to HTTP handler and initialize global…
omgitsads Jan 29, 2026
7801dc5
Merge branch 'oauth-handler-implementation' into scope-challenge-http
omgitsads Jan 29, 2026
4d679fd
Flags on the http command
omgitsads Jan 29, 2026
9a338d7
Add tests for scope maps
omgitsads Jan 30, 2026
7f6e0e8
Add scope challenge & pat filtering based on token scopes
omgitsads Jan 30, 2026
45ff914
Merge branch 'http-stack-2' into scope-challenge-http
omgitsads Jan 30, 2026
2b016e5
Add scope filtering if challenge is enabled
omgitsads Jan 30, 2026
f6d4337
Linter fixes and renamed scope challenge to PAT scope filter
omgitsads Jan 30, 2026
2b1b9bb
Linter issues.
omgitsads Jan 30, 2026
dad1e71
Remove unsused methoodod FetchScopesFromGitHubAPI, store active scope…
omgitsads Jan 30, 2026
ce05b87
Require an API host when creating scope fetchers
omgitsads Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ var (
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
RepoAccessCacheTTL: &ttl,
ScopeChallenge: viper.GetBool("scope-challenge"),
}

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

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
Expand All @@ -159,7 +161,7 @@ func init() {
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
_ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))

_ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)
Expand Down
9 changes: 1 addition & 8 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,7 @@ func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string,
return nil, fmt.Errorf("failed to parse API host: %w", err)
}

baseRestURL, err := apiHost.BaseRESTURL(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get base REST URL: %w", err)
}

fetcher := scopes.NewFetcher(scopes.FetcherOptions{
APIHost: baseRestURL.String(),
})
fetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{})

return fetcher.FetchTokenScopes(ctx, token)
}
39 changes: 39 additions & 0 deletions pkg/context/mcp_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package context

import "context"

type mcpMethodInfoCtx string

var mcpMethodInfoCtxKey mcpMethodInfoCtx = "mcpmethodinfo"

// MCPMethodInfo contains pre-parsed MCP method information extracted from the JSON-RPC request.
// This is populated early in the request lifecycle to enable:
// - Inventory filtering via ForMCPRequest (only register needed tools/resources/prompts)
// - Avoiding duplicate JSON parsing in middlewares (secret-scanning, scope-challenge)
// - Performance optimization for per-request server creation
type MCPMethodInfo struct {
// Method is the MCP method being called (e.g., "tools/call", "tools/list", "initialize")
Method string
// ItemName is the name of the specific item being accessed (tool name, resource URI, prompt name)
// Only populated for call/get methods (tools/call, prompts/get, resources/read)
ItemName string
// Owner is the repository owner from tool call arguments, if present
Owner string
// Repo is the repository name from tool call arguments, if present
Repo string
// Arguments contains the raw tool arguments for tools/call requests
Arguments map[string]any
}

// WithMCPMethodInfo stores the MCPMethodInfo in the context.
func WithMCPMethodInfo(ctx context.Context, info *MCPMethodInfo) context.Context {
return context.WithValue(ctx, mcpMethodInfoCtxKey, info)
}

// MCPMethod retrieves the MCPMethodInfo from the context.
func MCPMethod(ctx context.Context) (*MCPMethodInfo, bool) {
if info, ok := ctx.Value(mcpMethodInfoCtxKey).(*MCPMethodInfo); ok {
return info, true
}
return nil, false
}
36 changes: 28 additions & 8 deletions pkg/context/token.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
package context

import "context"
import (
"context"

"github.com/github/github-mcp-server/pkg/utils"
)

// tokenCtxKey is a context key for authentication token information
type tokenCtxKey struct{}
type tokenCtx string

var tokenCtxKey tokenCtx = "tokenctx"

type TokenInfo struct {
Token string
TokenType utils.TokenType
ScopesFetched bool
Scopes []string
}

// WithTokenInfo adds TokenInfo to the context
func WithTokenInfo(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, tokenCtxKey{}, token)
func WithTokenInfo(ctx context.Context, tokenInfo *TokenInfo) context.Context {
return context.WithValue(ctx, tokenCtxKey, tokenInfo)
}

func SetTokenScopes(ctx context.Context, scopes []string) {
if tokenInfo, ok := GetTokenInfo(ctx); ok {
tokenInfo.Scopes = scopes
tokenInfo.ScopesFetched = true
}
}

// GetTokenInfo retrieves the authentication token from the context
func GetTokenInfo(ctx context.Context) (string, bool) {
if token, ok := ctx.Value(tokenCtxKey{}).(string); ok {
return token, true
func GetTokenInfo(ctx context.Context) (*TokenInfo, bool) {
if tokenInfo, ok := ctx.Value(tokenCtxKey).(*TokenInfo); ok {
return tokenInfo, true
}
return "", false
return nil, false
}
12 changes: 10 additions & 2 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,11 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
}

// extract the token from the context
token, _ := ghcontext.GetTokenInfo(ctx)
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
if !ok {
return nil, fmt.Errorf("no token info in context")
}
token := tokenInfo.Token

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

// extract the token from the context
token, _ := ghcontext.GetTokenInfo(ctx)
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
if !ok {
return nil, fmt.Errorf("no token info in context")
}
token := tokenInfo.Token

// Construct GraphQL client
// We use NewEnterpriseClient unconditionally since we already parsed the API host
Expand Down
16 changes: 11 additions & 5 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"time"

ghcontext "github.com/github/github-mcp-server/pkg/context"
gherrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/octicons"
Expand Down Expand Up @@ -73,10 +74,10 @@ type MCPServerConfig struct {

type MCPServerOption func(*mcp.ServerOptions)

func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inventory *inventory.Inventory) (*mcp.Server, error) {
func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory) (*mcp.Server, error) {
// Create the MCP server
serverOpts := &mcp.ServerOptions{
Instructions: inventory.Instructions(),
Instructions: inv.Instructions(),
Logger: cfg.Logger,
CompletionHandler: CompletionsHandler(deps.GetClient),
}
Expand All @@ -102,20 +103,25 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps))

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

invToUse := inv
if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && methodInfo != nil {
invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName)
}

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

// Register dynamic toolset management tools (enable/disable) - these are separate
// meta-tools that control the inventory, not part of the inventory itself
if cfg.DynamicToolsets {
registerDynamicTools(ghServer, inventory, deps, cfg.Translator)
registerDynamicTools(ghServer, invToUse, deps, cfg.Translator)
}

return ghServer, nil
Expand Down
56 changes: 54 additions & 2 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"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"
)
Expand All @@ -23,21 +25,30 @@ type Handler struct {
config *ServerConfig
deps github.ToolDependencies
logger *slog.Logger
apiHosts utils.APIHostResolver
t translations.TranslationHelperFunc
githubMcpServerFactory GitHubMCPServerFactoryFunc
inventoryFactoryFunc InventoryFactoryFunc
oauthCfg *oauth.Config
scopeFetcher scopes.FetcherInterface
}

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
Expand Down Expand Up @@ -68,6 +79,7 @@ func NewHTTPMcpHandler(
deps github.ToolDependencies,
t translations.TranslationHelperFunc,
logger *slog.Logger,
apiHost utils.APIHostResolver,
options ...HandlerOption) *Handler {
opts := &HandlerOptions{}
for _, o := range options {
Expand All @@ -79,28 +91,40 @@ func NewHTTPMcpHandler(
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)
inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher)
}

return &Handler{
ctx: ctx,
config: cfg,
deps: deps,
logger: logger,
apiHosts: apiHost,
t: t,
githubMcpServerFactory: githubMcpServerFactory,
inventoryFactoryFunc: inventoryFactory,
oauthCfg: opts.OAuthConfig,
scopeFetcher: scopeFetcher,
}
}

func (h *Handler) RegisterMiddleware(r chi.Router) {
r.Use(
middleware.ExtractUserToken(h.oauthCfg),
middleware.WithRequestConfig,
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WithMCPParse middleware is defined but never registered in the middleware chain. The scope challenge middleware at line 85 mentions that it tries to use pre-parsed MCP method info "if WithMCPParse middleware ran earlier", but this middleware is not being registered anywhere. This means the optimization path will never be taken and the request body will always be parsed twice (once in WithScopeChallenge and again in downstream handlers). Consider adding middleware.WithMCPParse() to the middleware chain before WithScopeChallenge at line 122.

Suggested change
middleware.WithRequestConfig,
middleware.WithRequestConfig,
middleware.WithMCPParse(),

Copilot uses AI. Check for mistakes.
middleware.WithMCPParse(),
)

if h.config.ScopeChallenge {
r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher))
}
}

// RegisterRoutes registers the routes for the MCP server
Expand Down Expand Up @@ -177,13 +201,15 @@ func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies
}

// DefaultInventoryFactory creates the default inventory factory for HTTP mode
func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker) InventoryFactoryFunc {
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()
Expand Down Expand Up @@ -212,3 +238,29 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in

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
}

// Fetch token scopes for scope-based tool filtering (PAT tokens only)
// 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 {
scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token)
if err != nil {
return b
}

// Store fetched scopes in context for downstream use
ghcontext.SetTokenScopes(ctx, scopesList)

return b.WithFilter(github.CreateToolScopeFilter(scopesList))
}

return b
}
Loading