Skip to content

Commit 4b690f5

Browse files
committed
Add scopes challenge middleware to HTTP handler and initialize global tool scope map
1 parent 199e62c commit 4b690f5

File tree

6 files changed

+190
-14
lines changed

6 files changed

+190
-14
lines changed

cmd/github-mcp-server/main.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ func init() {
135135
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
136136
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
137137
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
138-
rootCmd.PersistentFlags().Int("port", 8082, "HTTP server port")
139138

140139
// Add port flag to http command
141140
httpCmd.PersistentFlags().Int("port", 8082, "HTTP server port")

pkg/http/handler.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,19 @@ func NewHTTPMcpHandler(
9090
}
9191
}
9292

93+
func (h *Handler) RegisterMiddleware(r chi.Router) {
94+
r.Use(
95+
middleware.ExtractUserToken(h.oauthCfg),
96+
middleware.WithRequestConfig,
97+
middleware.WithScopeChallenge(h.oauthCfg),
98+
)
99+
100+
r.Use(middleware.WithScopeChallenge(h.oauthCfg))
101+
}
102+
93103
// RegisterRoutes registers the routes for the MCP server
94104
// URL-based values take precedence over header-based values
95105
func (h *Handler) RegisterRoutes(r chi.Router) {
96-
r.Use(middleware.WithRequestConfig)
97-
98106
r.Mount("/", h)
99107
// Mount readonly and toolset routes
100108
r.With(withToolset).Mount("/x/{toolset}", h)
@@ -144,7 +152,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
144152
Stateless: true,
145153
})
146154

147-
middleware.ExtractUserToken(h.oauthCfg)(mcpHandler).ServeHTTP(w, r)
155+
mcpHandler.ServeHTTP(w, r)
148156
}
149157

150158
func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {

pkg/http/middleware/scope_challenge.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import (
1212

1313
ghcontext "github.com/github/github-mcp-server/pkg/context"
1414
"github.com/github/github-mcp-server/pkg/http/oauth"
15+
"github.com/github/github-mcp-server/pkg/scopes"
1516
"github.com/github/github-mcp-server/pkg/utils"
1617
)
1718

1819
// FetchScopesFromGitHubAPI fetches the OAuth scopes from the GitHub API by making
1920
// a HEAD request and reading the X-OAuth-Scopes header. This is used as a fallback
2021
// when scopes are not provided in the token info header.
21-
func FetchScopesFromGitHubAPI(ctx context.Context, token string, apiUrls *utils.APIHost) ([]string, error) {
22-
baseUrl, err := apiUrls.BaseRESTURL(ctx)
22+
func FetchScopesFromGitHubAPI(ctx context.Context, token string, apiHost utils.APIHostResolver) ([]string, error) {
23+
baseUrl, err := apiHost.BaseRESTURL(ctx)
2324
if err != nil {
2425
return nil, err
2526
}
@@ -56,7 +57,7 @@ func FetchScopesFromGitHubAPI(ctx context.Context, token string, apiUrls *utils.
5657

5758
// WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to
5859
// complete the request and returns a scope challenge if not.
59-
func WithScopeChallenge(oauthCfg *oauth.Config, apiUrls *utils.APIHost) func(http.Handler) http.Handler {
60+
func WithScopeChallenge(oauthCfg *oauth.Config) func(http.Handler) http.Handler {
6061
return func(next http.Handler) http.Handler {
6162
fn := func(w http.ResponseWriter, r *http.Request) {
6263
ctx := r.Context()
@@ -135,7 +136,7 @@ func WithScopeChallenge(oauthCfg *oauth.Config, apiUrls *utils.APIHost) func(htt
135136
}
136137

137138
// Get OAuth scopes from GitHub API
138-
activeScopes, err := FetchScopesFromGitHubAPI(ctx, tokenInfo.Token, apiUrls)
139+
activeScopes, err := FetchScopesFromGitHubAPI(ctx, tokenInfo.Token, oauthCfg.ApiHosts)
139140

140141
// Check if user has the required scopes
141142
if toolScopeInfo.HasAcceptedScope(activeScopes...) {

pkg/http/oauth/oauth.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010

1111
"github.com/github/github-mcp-server/pkg/http/headers"
12+
"github.com/github/github-mcp-server/pkg/utils"
1213
"github.com/go-chi/chi/v5"
1314
"github.com/modelcontextprotocol/go-sdk/auth"
1415
"github.com/modelcontextprotocol/go-sdk/oauthex"
@@ -40,6 +41,8 @@ var SupportedScopes = []string{
4041

4142
// Config holds the OAuth configuration for the MCP server.
4243
type Config struct {
44+
ApiHosts utils.APIHostResolver
45+
4346
// BaseURL is the publicly accessible URL where this server is hosted.
4447
// This is used to construct the OAuth resource URL.
4548
BaseURL string

pkg/http/server.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import (
1313

1414
"github.com/github/github-mcp-server/pkg/github"
1515
"github.com/github/github-mcp-server/pkg/http/oauth"
16+
"github.com/github/github-mcp-server/pkg/inventory"
1617
"github.com/github/github-mcp-server/pkg/lockdown"
18+
"github.com/github/github-mcp-server/pkg/scopes"
1719
"github.com/github/github-mcp-server/pkg/translations"
1820
"github.com/github/github-mcp-server/pkg/utils"
1921
"github.com/go-chi/chi/v5"
@@ -98,21 +100,39 @@ func RunHTTPServer(cfg ServerConfig) error {
98100
nil,
99101
)
100102

101-
r := chi.NewRouter()
103+
// Initialize the global tool scope map
104+
err = initGlobalToolScopeMap(t)
105+
if err != nil {
106+
return fmt.Errorf("failed to initialize tool scope map: %w", err)
107+
}
102108

103109
// Register OAuth protected resource metadata endpoints
104110
oauthCfg := &oauth.Config{
105-
BaseURL: cfg.BaseURL,
111+
BaseURL: cfg.BaseURL,
112+
ApiHosts: apiHost,
106113
}
114+
115+
r := chi.NewRouter()
116+
handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, WithOAuthConfig(oauthCfg))
107117
oauthHandler, err := oauth.NewAuthHandler(oauthCfg)
108118
if err != nil {
109119
return fmt.Errorf("failed to create OAuth handler: %w", err)
110120
}
111-
oauthHandler.RegisterRoutes(r)
112-
logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
113121

114-
handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, WithOAuthConfig(oauthCfg))
115-
handler.RegisterRoutes(r)
122+
r.Group(func(r chi.Router) {
123+
// Register Middleware First, needs to be before route registration
124+
handler.RegisterMiddleware(r)
125+
126+
// Register MCP server routes
127+
handler.RegisterRoutes(r)
128+
logger.Info("MCP server routes registered")
129+
})
130+
131+
r.Group(func(r chi.Router) {
132+
// Register OAuth protected resource metadata endpoints
133+
oauthHandler.RegisterRoutes(r)
134+
logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
135+
})
116136

117137
addr := fmt.Sprintf(":%d", cfg.Port)
118138
httpSvr := http.Server{
@@ -144,3 +164,19 @@ func RunHTTPServer(cfg ServerConfig) error {
144164
logger.Info("server stopped gracefully")
145165
return nil
146166
}
167+
168+
func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {
169+
// Build inventory with all tools to extract scope information
170+
inv, err := inventory.NewBuilder().
171+
SetTools(github.AllTools(t)).
172+
Build()
173+
174+
if err != nil {
175+
return fmt.Errorf("failed to build inventory for tool scope map: %w", err)
176+
}
177+
178+
// Initialize the global scope map
179+
scopes.SetToolScopeMapFromInventory(inv)
180+
181+
return nil
182+
}

pkg/scopes/map.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package scopes
2+
3+
import "github.com/github/github-mcp-server/pkg/inventory"
4+
5+
// ToolScopeMap maps tool names to their scope requirements.
6+
type ToolScopeMap map[string]*ToolScopeInfo
7+
8+
// ToolScopeInfo contains scope information for a single tool.
9+
type ToolScopeInfo struct {
10+
// RequiredScopes contains the scopes that are directly required by this tool.
11+
RequiredScopes []string
12+
13+
// AcceptedScopes contains all scopes that satisfy the requirements (including parent scopes).
14+
AcceptedScopes []string
15+
}
16+
17+
// globalToolScopeMap is populated from inventory when SetToolScopeMapFromInventory is called
18+
var globalToolScopeMap ToolScopeMap
19+
20+
// SetToolScopeMapFromInventory builds and stores a tool scope map from an inventory.
21+
// This should be called after building the inventory to make scopes available for middleware.
22+
func SetToolScopeMapFromInventory(inv *inventory.Inventory) {
23+
globalToolScopeMap = GetToolScopeMapFromInventory(inv)
24+
}
25+
26+
// SetGlobalToolScopeMap sets the global tool scope map directly.
27+
// This is useful for testing when you don't have a full inventory.
28+
func SetGlobalToolScopeMap(m ToolScopeMap) {
29+
globalToolScopeMap = m
30+
}
31+
32+
// GetToolScopeMap returns the global tool scope map.
33+
// Returns an empty map if SetToolScopeMapFromInventory hasn't been called yet.
34+
func GetToolScopeMap() (ToolScopeMap, error) {
35+
if globalToolScopeMap == nil {
36+
return make(ToolScopeMap), nil
37+
}
38+
return globalToolScopeMap, nil
39+
}
40+
41+
// GetToolScopeInfo returns scope information for a specific tool from the global scope map.
42+
func GetToolScopeInfo(toolName string) (*ToolScopeInfo, error) {
43+
m, err := GetToolScopeMap()
44+
if err != nil {
45+
return nil, err
46+
}
47+
return m[toolName], nil
48+
}
49+
50+
// GetToolScopeMapFromInventory builds a tool scope map from an inventory.
51+
// This extracts scope information from ServerTool.RequiredScopes and ServerTool.AcceptedScopes.
52+
func GetToolScopeMapFromInventory(inv *inventory.Inventory) ToolScopeMap {
53+
result := make(ToolScopeMap)
54+
55+
// Get all tools from the inventory (both enabled and disabled)
56+
// We need all tools for scope checking purposes
57+
allTools := inv.AllTools()
58+
for i := range allTools {
59+
tool := &allTools[i]
60+
if len(tool.RequiredScopes) > 0 || len(tool.AcceptedScopes) > 0 {
61+
result[tool.Tool.Name] = &ToolScopeInfo{
62+
RequiredScopes: tool.RequiredScopes,
63+
AcceptedScopes: tool.AcceptedScopes,
64+
}
65+
}
66+
}
67+
68+
return result
69+
}
70+
71+
// HasAcceptedScope checks if any of the provided user scopes satisfy the tool's requirements.
72+
func (t *ToolScopeInfo) HasAcceptedScope(userScopes ...string) bool {
73+
if t == nil || len(t.AcceptedScopes) == 0 {
74+
return true // No scopes required
75+
}
76+
77+
userScopeSet := make(map[string]bool)
78+
for _, scope := range userScopes {
79+
userScopeSet[scope] = true
80+
}
81+
82+
for _, scope := range t.AcceptedScopes {
83+
if userScopeSet[scope] {
84+
return true
85+
}
86+
}
87+
return false
88+
}
89+
90+
// MissingScopes returns the required scopes that are not present in the user's scopes.
91+
func (t *ToolScopeInfo) MissingScopes(userScopes ...string) []string {
92+
if t == nil || len(t.RequiredScopes) == 0 {
93+
return nil
94+
}
95+
96+
// Create a set of user scopes for O(1) lookup
97+
userScopeSet := make(map[string]bool, len(userScopes))
98+
for _, s := range userScopes {
99+
userScopeSet[s] = true
100+
}
101+
102+
// Check if any accepted scope is present
103+
hasAccepted := false
104+
for _, scope := range t.AcceptedScopes {
105+
if userScopeSet[scope] {
106+
hasAccepted = true
107+
break
108+
}
109+
}
110+
111+
if hasAccepted {
112+
return nil // User has sufficient scopes
113+
}
114+
115+
// Return required scopes as the minimum needed
116+
missing := make([]string, len(t.RequiredScopes))
117+
copy(missing, t.RequiredScopes)
118+
return missing
119+
}
120+
121+
// GetRequiredScopesSlice returns the required scopes as a slice of strings.
122+
func (t *ToolScopeInfo) GetRequiredScopesSlice() []string {
123+
if t == nil {
124+
return nil
125+
}
126+
scopes := make([]string, len(t.RequiredScopes))
127+
copy(scopes, t.RequiredScopes)
128+
return scopes
129+
}

0 commit comments

Comments
 (0)