Skip to content

Commit cdd35ac

Browse files
Add PAT scope filtering for stdio server
Add the ability to filter tools based on token scopes for PAT users. This uses an HTTP HEAD request to GitHub's API to discover token scopes. New components: - pkg/scopes/filter.go: HasRequiredScopes checks if scopes satisfy tool requirements - pkg/scopes/fetcher.go: FetchTokenScopes gets scopes via HTTP HEAD to GitHub API - pkg/github/scope_filter.go: CreateScopeFilter creates inventory.ToolFilter Integration: - Add --filter-by-scope flag to stdio command (disabled by default) - When enabled, fetches token scopes on startup - Tools requiring unavailable scopes are hidden from tool list - Gracefully continues without filtering if scope fetch fails (logs warning) This allows the OSS server to have similar scope-based tool visibility as the remote server, and the filter logic can be reused by remote server.
1 parent c4c6491 commit cdd35ac

File tree

10 files changed

+827
-9
lines changed

10 files changed

+827
-9
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ The following sets of tools are available:
602602

603603
- **get_code_scanning_alert** - Get code scanning alert
604604
- **Required OAuth Scopes**: `security_events`
605-
- **Accepted OAuth Scopes**: `security_events`, `repo`
605+
- **Accepted OAuth Scopes**: `repo`, `security_events`
606606
- `alertNumber`: The number of the alert. (number, required)
607607
- `owner`: The owner of the repository. (string, required)
608608
- `repo`: The name of the repository. (string, required)
@@ -628,7 +628,7 @@ The following sets of tools are available:
628628

629629
- **get_team_members** - Get team members
630630
- **Required OAuth Scopes**: `read:org`
631-
- **Accepted OAuth Scopes**: `write:org`, `read:org`, `admin:org`
631+
- **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org`
632632
- `org`: Organization login (owner) that contains the team. (string, required)
633633
- `team_slug`: Team slug (string, required)
634634

@@ -953,15 +953,15 @@ The following sets of tools are available:
953953

954954
- **get_project_field** - Get project field
955955
- **Required OAuth Scopes**: `read:project`
956-
- **Accepted OAuth Scopes**: `read:project`, `project`
956+
- **Accepted OAuth Scopes**: `project`, `read:project`
957957
- `field_id`: The field's id. (number, required)
958958
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
959959
- `owner_type`: Owner type (string, required)
960960
- `project_number`: The project's number. (number, required)
961961

962962
- **get_project_item** - Get project item
963963
- **Required OAuth Scopes**: `read:project`
964-
- **Accepted OAuth Scopes**: `project`, `read:project`
964+
- **Accepted OAuth Scopes**: `read:project`, `project`
965965
- `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional)
966966
- `item_id`: The item's ID. (number, required)
967967
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
@@ -980,7 +980,7 @@ The following sets of tools are available:
980980

981981
- **list_project_items** - List project items
982982
- **Required OAuth Scopes**: `read:project`
983-
- **Accepted OAuth Scopes**: `read:project`, `project`
983+
- **Accepted OAuth Scopes**: `project`, `read:project`
984984
- `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional)
985985
- `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional)
986986
- `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional)

cmd/github-mcp-server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ var (
8484
ContentWindowSize: viper.GetInt("content-window-size"),
8585
LockdownMode: viper.GetBool("lockdown-mode"),
8686
RepoAccessCacheTTL: &ttl,
87+
EnableScopeFiltering: viper.GetBool("enable-scope-filtering"),
8788
}
8889
return ghmcp.RunStdioServer(stdioServerConfig)
8990
},
@@ -109,6 +110,7 @@ func init() {
109110
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
110111
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
111112
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
113+
rootCmd.PersistentFlags().Bool("enable-scope-filtering", false, "Filter tools based on the token's OAuth scopes")
112114

113115
// Bind flag to viper
114116
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -123,6 +125,7 @@ func init() {
123125
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
124126
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
125127
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
128+
_ = viper.BindPFlag("enable-scope-filtering", rootCmd.PersistentFlags().Lookup("enable-scope-filtering"))
126129

127130
// Add subcommands
128131
rootCmd.AddCommand(stdioCmd)

internal/ghmcp/server.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/github/github-mcp-server/pkg/lockdown"
2020
mcplog "github.com/github/github-mcp-server/pkg/log"
2121
"github.com/github/github-mcp-server/pkg/raw"
22+
"github.com/github/github-mcp-server/pkg/scopes"
2223
"github.com/github/github-mcp-server/pkg/translations"
2324
gogithub "github.com/google/go-github/v79/github"
2425
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -67,6 +68,11 @@ type MCPServerConfig struct {
6768
Logger *slog.Logger
6869
// RepoAccessTTL overrides the default TTL for repository access cache entries.
6970
RepoAccessTTL *time.Duration
71+
72+
// TokenScopes contains the OAuth scopes available to the token.
73+
// When non-nil, tools requiring scopes not in this list will be hidden.
74+
// This is used for PAT scope filtering where we can't issue scope challenges.
75+
TokenScopes []string
7076
}
7177

7278
// githubClients holds all the GitHub API clients created for a server instance.
@@ -211,13 +217,19 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
211217
})
212218

213219
// Build and register the tool/resource/prompt inventory
214-
inventory := github.NewInventory(cfg.Translator).
220+
inventoryBuilder := github.NewInventory(cfg.Translator).
215221
WithDeprecatedAliases(github.DeprecatedToolAliases).
216222
WithReadOnly(cfg.ReadOnly).
217223
WithToolsets(enabledToolsets).
218224
WithTools(github.CleanTools(cfg.EnabledTools)).
219-
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)).
220-
Build()
225+
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
226+
227+
// Apply token scope filtering if scopes are known (for PAT filtering)
228+
if cfg.TokenScopes != nil {
229+
inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
230+
}
231+
232+
inventory := inventoryBuilder.Build()
221233

222234
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
223235
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))
@@ -312,6 +324,11 @@ type StdioServerConfig struct {
312324

313325
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
314326
RepoAccessCacheTTL *time.Duration
327+
328+
// EnableScopeFiltering enables PAT scope-based tool filtering.
329+
// When true, the server will fetch the token's OAuth scopes at startup
330+
// and hide tools that require scopes the token doesn't have.
331+
EnableScopeFiltering bool
315332
}
316333

317334
// RunStdioServer is not concurrent safe.
@@ -336,7 +353,19 @@ func RunStdioServer(cfg StdioServerConfig) error {
336353
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
337354
}
338355
logger := slog.New(slogHandler)
339-
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
356+
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode, "scopeFiltering", cfg.EnableScopeFiltering)
357+
358+
// Fetch token scopes if scope filtering is enabled
359+
var tokenScopes []string
360+
if cfg.EnableScopeFiltering {
361+
fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)
362+
if err != nil {
363+
logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err)
364+
} else {
365+
tokenScopes = fetchedScopes
366+
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
367+
}
368+
}
340369

341370
ghServer, err := NewMCPServer(MCPServerConfig{
342371
Version: cfg.Version,
@@ -352,6 +381,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
352381
LockdownMode: cfg.LockdownMode,
353382
Logger: logger,
354383
RepoAccessTTL: cfg.RepoAccessCacheTTL,
384+
TokenScopes: tokenScopes,
355385
})
356386
if err != nil {
357387
return fmt.Errorf("failed to create MCP server: %w", err)
@@ -636,3 +666,18 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g
636666
}
637667
}
638668
}
669+
670+
// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API.
671+
// It constructs the appropriate API host URL based on the configured host.
672+
func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) {
673+
apiHost, err := parseAPIHost(host)
674+
if err != nil {
675+
return nil, fmt.Errorf("failed to parse API host: %w", err)
676+
}
677+
678+
fetcher := scopes.NewFetcher(scopes.FetcherOptions{
679+
APIHost: apiHost.baseRESTURL.String(),
680+
})
681+
682+
return fetcher.FetchTokenScopes(ctx, token)
683+
}

pkg/github/scope_filter.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package github
2+
3+
import (
4+
"context"
5+
6+
"github.com/github/github-mcp-server/pkg/inventory"
7+
"github.com/github/github-mcp-server/pkg/scopes"
8+
)
9+
10+
// CreateToolScopeFilter creates an inventory.ToolFilter that filters tools
11+
// based on the token's OAuth scopes.
12+
//
13+
// For PATs (Personal Access Tokens), we cannot issue OAuth scope challenges
14+
// like we can with OAuth apps. Instead, we hide tools that require scopes
15+
// the token doesn't have.
16+
//
17+
// This is the recommended way to filter tools for stdio servers where the
18+
// token is known at startup and won't change during the session.
19+
//
20+
// The filter returns true (include tool) if:
21+
// - The tool has no scope requirements (AcceptedScopes is empty)
22+
// - The token has at least one of the tool's accepted scopes
23+
//
24+
// Example usage:
25+
//
26+
// tokenScopes, err := scopes.FetchTokenScopes(ctx, token)
27+
// if err != nil {
28+
// // Handle error - maybe skip filtering
29+
// }
30+
// filter := github.CreateToolScopeFilter(tokenScopes)
31+
// inventory := github.NewInventory(t).WithFilter(filter).Build()
32+
func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter {
33+
return func(_ context.Context, tool *inventory.ServerTool) (bool, error) {
34+
return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil
35+
}
36+
}

pkg/github/scope_filter_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/github/github-mcp-server/pkg/inventory"
8+
"github.com/modelcontextprotocol/go-sdk/mcp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestCreateToolScopeFilter(t *testing.T) {
14+
// Create test tools with various scope requirements
15+
toolNoScopes := &inventory.ServerTool{
16+
Tool: mcp.Tool{Name: "no_scopes_tool"},
17+
AcceptedScopes: nil,
18+
}
19+
20+
toolEmptyScopes := &inventory.ServerTool{
21+
Tool: mcp.Tool{Name: "empty_scopes_tool"},
22+
AcceptedScopes: []string{},
23+
}
24+
25+
toolRepoScope := &inventory.ServerTool{
26+
Tool: mcp.Tool{Name: "repo_tool"},
27+
AcceptedScopes: []string{"repo"},
28+
}
29+
30+
toolPublicRepoScope := &inventory.ServerTool{
31+
Tool: mcp.Tool{Name: "public_repo_tool"},
32+
AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted
33+
}
34+
35+
toolGistScope := &inventory.ServerTool{
36+
Tool: mcp.Tool{Name: "gist_tool"},
37+
AcceptedScopes: []string{"gist"},
38+
}
39+
40+
toolMultiScope := &inventory.ServerTool{
41+
Tool: mcp.Tool{Name: "multi_scope_tool"},
42+
AcceptedScopes: []string{"repo", "admin:org"},
43+
}
44+
45+
tests := []struct {
46+
name string
47+
tokenScopes []string
48+
tool *inventory.ServerTool
49+
expected bool
50+
}{
51+
{
52+
name: "tool with no scopes is always visible",
53+
tokenScopes: []string{},
54+
tool: toolNoScopes,
55+
expected: true,
56+
},
57+
{
58+
name: "tool with empty scopes is always visible",
59+
tokenScopes: []string{"repo"},
60+
tool: toolEmptyScopes,
61+
expected: true,
62+
},
63+
{
64+
name: "token with exact scope can see tool",
65+
tokenScopes: []string{"repo"},
66+
tool: toolRepoScope,
67+
expected: true,
68+
},
69+
{
70+
name: "token with parent scope can see child-scoped tool",
71+
tokenScopes: []string{"repo"},
72+
tool: toolPublicRepoScope,
73+
expected: true,
74+
},
75+
{
76+
name: "token missing required scope cannot see tool",
77+
tokenScopes: []string{"gist"},
78+
tool: toolRepoScope,
79+
expected: false,
80+
},
81+
{
82+
name: "token with unrelated scope cannot see tool",
83+
tokenScopes: []string{"repo"},
84+
tool: toolGistScope,
85+
expected: false,
86+
},
87+
{
88+
name: "token with one of multiple accepted scopes can see tool",
89+
tokenScopes: []string{"admin:org"},
90+
tool: toolMultiScope,
91+
expected: true,
92+
},
93+
{
94+
name: "empty token scopes cannot see scoped tools",
95+
tokenScopes: []string{},
96+
tool: toolRepoScope,
97+
expected: false,
98+
},
99+
{
100+
name: "token with multiple scopes where one matches",
101+
tokenScopes: []string{"gist", "repo"},
102+
tool: toolPublicRepoScope,
103+
expected: true,
104+
},
105+
}
106+
107+
for _, tt := range tests {
108+
t.Run(tt.name, func(t *testing.T) {
109+
filter := CreateToolScopeFilter(tt.tokenScopes)
110+
result, err := filter(context.Background(), tt.tool)
111+
112+
require.NoError(t, err)
113+
assert.Equal(t, tt.expected, result, "filter result should match expected")
114+
})
115+
}
116+
}
117+
118+
func TestCreateToolScopeFilter_Integration(t *testing.T) {
119+
// Test integration with inventory builder
120+
tools := []inventory.ServerTool{
121+
{
122+
Tool: mcp.Tool{Name: "public_tool"},
123+
Toolset: inventory.ToolsetMetadata{ID: "test"},
124+
AcceptedScopes: nil, // No scopes required
125+
},
126+
{
127+
Tool: mcp.Tool{Name: "repo_tool"},
128+
Toolset: inventory.ToolsetMetadata{ID: "test"},
129+
AcceptedScopes: []string{"repo"},
130+
},
131+
{
132+
Tool: mcp.Tool{Name: "gist_tool"},
133+
Toolset: inventory.ToolsetMetadata{ID: "test"},
134+
AcceptedScopes: []string{"gist"},
135+
},
136+
}
137+
138+
// Create filter for token with only "repo" scope
139+
filter := CreateToolScopeFilter([]string{"repo"})
140+
141+
// Build inventory with the filter
142+
inv := inventory.NewBuilder().
143+
SetTools(tools).
144+
WithToolsets([]string{"test"}).
145+
WithFilter(filter).
146+
Build()
147+
148+
// Get available tools
149+
availableTools := inv.AvailableTools(context.Background())
150+
151+
// Should see public_tool and repo_tool, but not gist_tool
152+
assert.Len(t, availableTools, 2)
153+
154+
toolNames := make([]string, len(availableTools))
155+
for i, tool := range availableTools {
156+
toolNames[i] = tool.Tool.Name
157+
}
158+
159+
assert.Contains(t, toolNames, "public_tool")
160+
assert.Contains(t, toolNames, "repo_tool")
161+
assert.NotContains(t, toolNames, "gist_tool")
162+
}

0 commit comments

Comments
 (0)