Skip to content

Commit de68b99

Browse files
Merge branch 'main' into consolidate-project-tools
2 parents a8b6188 + 7d4a4a6 commit de68b99

File tree

9 files changed

+982
-3
lines changed

9 files changed

+982
-3
lines changed

docs/scope-filtering.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# PAT Scope Filtering
2+
3+
The GitHub MCP Server automatically filters available tools based on your classic Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform.
4+
5+
> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs, GitHub App installation tokens, and server-to-server tokens don't support scope detection and show all tools.
6+
7+
## How It Works
8+
9+
When the server starts with a classic PAT, it makes a lightweight HTTP HEAD request to the GitHub API to discover your token's scopes from the `X-OAuth-Scopes` header. Tools that require scopes your token doesn't have are automatically hidden.
10+
11+
**Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes.
12+
13+
## PAT vs OAuth Authentication
14+
15+
| Authentication | Scope Handling |
16+
|---------------|----------------|
17+
| **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden |
18+
| **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it |
19+
| **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions |
20+
| **GitHub App** (`ghs_`) | No filtering—all tools shown, permissions based on app installation |
21+
| **Server-to-server** | No filtering—all tools shown, permissions based on app/token configuration |
22+
23+
With OAuth, the remote server can dynamically request additional scopes as needed. With PATs, scopes are fixed at token creation, so the server proactively hides tools you can't use.
24+
25+
## OAuth Scope Challenges (Remote Server)
26+
27+
When using the [remote MCP server](./remote-server.md) with OAuth authentication, the server uses a different approach called **scope challenges**. Instead of hiding tools upfront, all tools are available, and the server requests additional scopes on-demand when you try to use a tool that requires them.
28+
29+
**How it works:**
30+
1. You attempt to use a tool (e.g., creating an issue)
31+
2. If your current OAuth token lacks the required scope, the server returns an OAuth scope challenge
32+
3. Your MCP client prompts you to authorize the additional scope
33+
4. After authorization, the operation completes successfully
34+
35+
This provides a smoother user experience for OAuth users since you only grant permissions as needed, rather than requesting all scopes upfront.
36+
37+
## Checking Your Token's Scopes
38+
39+
To see what scopes your token has, you can run:
40+
41+
```bash
42+
curl -sI -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \
43+
https://api.github.com/user | grep -i x-oauth-scopes
44+
```
45+
46+
Example output:
47+
```
48+
x-oauth-scopes: delete_repo, gist, read:org, repo
49+
```
50+
51+
## Scope Hierarchy
52+
53+
Some scopes implicitly include others:
54+
55+
- `repo` → includes `public_repo`, `security_events`
56+
- `admin:org` → includes `write:org` → includes `read:org`
57+
- `project` → includes `read:project`
58+
59+
This means if your token has `repo`, tools requiring `security_events` will also be available.
60+
61+
Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes.
62+
63+
## Public Repository Access
64+
65+
Read-only tools that only require `repo` or `public_repo` scopes are **always visible**, even if your token doesn't have these scopes. This is because these tools work on public repositories without authentication.
66+
67+
For example, `get_file_contents` is always available—you can read files from any public repository regardless of your token's scopes. However, write operations like `create_or_update_file` will be hidden if your token lacks `repo` scope.
68+
69+
> **Note:** The GitHub API doesn't return `public_repo` in the `X-OAuth-Scopes` header—it's implicit. The server handles this by not filtering read-only repository tools.
70+
71+
## Graceful Degradation
72+
73+
If the server cannot fetch your token's scopes (e.g., network issues, rate limiting), it logs a warning and continues **without filtering**. This ensures the server remains usable even when scope detection fails.
74+
75+
```
76+
WARN: failed to fetch token scopes, continuing without scope filtering
77+
```
78+
79+
## Classic vs Fine-Grained Personal Access Tokens
80+
81+
**Classic PATs** (`ghp_` prefix) support OAuth scopes and return them in the `X-OAuth-Scopes` header. Scope filtering works fully with these tokens.
82+
83+
**Fine-grained PATs** (`github_pat_` prefix) use a different permission model based on repository access and specific permissions rather than OAuth scopes. They don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. All tools will be available, but the GitHub API will still enforce permissions at the API level—you'll get errors if you try to use tools your token doesn't have permission for.
84+
85+
## GitHub App and Server-to-Server Tokens
86+
87+
**GitHub App installation tokens** (`ghs_` prefix) and other server-to-server tokens use a permission model based on the app's installation permissions rather than OAuth scopes. These tokens don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. The GitHub API enforces permissions based on the app's configuration.
88+
89+
## Troubleshooting
90+
91+
| Problem | Cause | Solution |
92+
|---------|-------|----------|
93+
| Missing expected tools | Token lacks required scope | [Edit your PAT's scopes](https://github.com/settings/tokens) in GitHub settings |
94+
| All tools visible despite limited PAT | Scope detection failed | Check logs for warnings about scope fetching |
95+
| "Insufficient permissions" errors | Tool visible but scope insufficient | This shouldn't happen with scope filtering; report as bug |
96+
97+
> **Tip:** You can adjust the scopes of an existing classic PAT at any time via [GitHub's token settings](https://github.com/settings/tokens). After updating scopes, restart the MCP server to pick up the changes.
98+
99+
## Related Documentation
100+
101+
- [Server Configuration Guide](./server-configuration.md)
102+
- [GitHub PAT Documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
103+
- [OAuth Scopes Reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

docs/server-configuration.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
1212
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
1313
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
1414
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
15+
| Scope Filtering | Always enabled | Always enabled |
1516

1617
> **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`.
1718
@@ -330,6 +331,20 @@ Lockdown mode ensures the server only surfaces content in public repositories fr
330331

331332
---
332333

334+
### Scope Filtering
335+
336+
**Automatic feature:** The server handles OAuth scopes differently depending on authentication type:
337+
338+
- **Classic PATs** (`ghp_` prefix): Tools are filtered at startup based on token scopes—you only see tools you have permission to use
339+
- **OAuth** (remote server): Uses scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it
340+
- **Other tokens**: No filtering—all tools shown, API enforces permissions
341+
342+
This happens transparently—no configuration needed. If scope detection fails for a classic PAT (e.g., network issues), the server logs a warning and continues with all tools available.
343+
344+
See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types.
345+
346+
---
347+
333348
## Troubleshooting
334349

335350
| Problem | Cause | Solution |

internal/ghmcp/server.go

Lines changed: 47 additions & 3 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, ", "))
@@ -338,6 +350,22 @@ func RunStdioServer(cfg StdioServerConfig) error {
338350
logger := slog.New(slogHandler)
339351
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
340352

353+
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
354+
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
355+
// Fine-grained PATs and other token types don't support this, so we skip filtering.
356+
var tokenScopes []string
357+
if strings.HasPrefix(cfg.Token, "ghp_") {
358+
fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)
359+
if err != nil {
360+
logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err)
361+
} else {
362+
tokenScopes = fetchedScopes
363+
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
364+
}
365+
} else {
366+
logger.Debug("skipping scope filtering for non-PAT token")
367+
}
368+
341369
ghServer, err := NewMCPServer(MCPServerConfig{
342370
Version: cfg.Version,
343371
Host: cfg.Host,
@@ -352,6 +380,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
352380
LockdownMode: cfg.LockdownMode,
353381
Logger: logger,
354382
RepoAccessTTL: cfg.RepoAccessCacheTTL,
383+
TokenScopes: tokenScopes,
355384
})
356385
if err != nil {
357386
return fmt.Errorf("failed to create MCP server: %w", err)
@@ -636,3 +665,18 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g
636665
}
637666
}
638667
}
668+
669+
// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API.
670+
// It constructs the appropriate API host URL based on the configured host.
671+
func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) {
672+
apiHost, err := parseAPIHost(host)
673+
if err != nil {
674+
return nil, fmt.Errorf("failed to parse API host: %w", err)
675+
}
676+
677+
fetcher := scopes.NewFetcher(scopes.FetcherOptions{
678+
APIHost: apiHost.baseRESTURL.String(),
679+
})
680+
681+
return fetcher.FetchTokenScopes(ctx, token)
682+
}

pkg/github/scope_filter.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
// repoScopesSet contains scopes that grant access to repository content.
11+
// Tools requiring only these scopes work on public repos without any token scope,
12+
// so we don't filter them out even if the token lacks repo/public_repo.
13+
var repoScopesSet = map[string]bool{
14+
string(scopes.Repo): true,
15+
string(scopes.PublicRepo): true,
16+
}
17+
18+
// onlyRequiresRepoScopes returns true if all of the tool's accepted scopes
19+
// are repo-related scopes (repo, public_repo). Such tools work on public
20+
// repositories without needing any scope.
21+
func onlyRequiresRepoScopes(acceptedScopes []string) bool {
22+
if len(acceptedScopes) == 0 {
23+
return false
24+
}
25+
for _, scope := range acceptedScopes {
26+
if !repoScopesSet[scope] {
27+
return false
28+
}
29+
}
30+
return true
31+
}
32+
33+
// CreateToolScopeFilter creates an inventory.ToolFilter that filters tools
34+
// based on the token's OAuth scopes.
35+
//
36+
// For PATs (Personal Access Tokens), we cannot issue OAuth scope challenges
37+
// like we can with OAuth apps. Instead, we hide tools that require scopes
38+
// the token doesn't have.
39+
//
40+
// This is the recommended way to filter tools for stdio servers where the
41+
// token is known at startup and won't change during the session.
42+
//
43+
// The filter returns true (include tool) if:
44+
// - The tool has no scope requirements (AcceptedScopes is empty)
45+
// - The tool is read-only and only requires repo/public_repo scopes (works on public repos)
46+
// - The token has at least one of the tool's accepted scopes
47+
//
48+
// Example usage:
49+
//
50+
// tokenScopes, err := scopes.FetchTokenScopes(ctx, token)
51+
// if err != nil {
52+
// // Handle error - maybe skip filtering
53+
// }
54+
// filter := github.CreateToolScopeFilter(tokenScopes)
55+
// inventory := github.NewInventory(t).WithFilter(filter).Build()
56+
func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter {
57+
return func(_ context.Context, tool *inventory.ServerTool) (bool, error) {
58+
// Read-only tools requiring only repo/public_repo work on public repos without any scope
59+
if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.AcceptedScopes) {
60+
return true, nil
61+
}
62+
return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil
63+
}
64+
}

0 commit comments

Comments
 (0)