Skip to content

Commit e10fbfe

Browse files
Document scope-filter limitations and fail-open posture
Correct the over-claim that the scope hierarchy covers all OR cases. The hierarchy only models ancestor substitution, not sibling alternatives, so it cannot represent every way a tool may legitimately be satisfied. - Reword HasRequiredScopes doc: AND-of-ORs is ancestor-only; note the best-effort/fail-open posture and make the future CNF extension concrete using code scanning as the motivating example (security_events OR public_repo OR repo). - Note in CreateToolScopeFilter that filtering is a best-effort UX filter, not an authorization boundary (gated to ghp_ PATs, skipped when scopes can't be fetched). - Verify security tools ({security_events}) stay behavior-neutral under AND: a repo token still satisfies them; a public_repo-only token was already hidden before. Add explicit neutrality tests at the scope and filter layers. - docs/scope-filtering.md: add a Limitations and Fail-Open Posture section (sibling scopes, org roles, repo visibility) and a best-effort intro note. No tool declarations or scopes changed; no CNF structure built. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0de95e0 commit e10fbfe

5 files changed

Lines changed: 86 additions & 12 deletions

File tree

docs/scope-filtering.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ The GitHub MCP Server automatically filters available tools based on your classi
44

55
> **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.
66
7+
> **Important:** Scope filtering is a best-effort UX convenience, **not an authorization boundary**. The GitHub API is always the source of truth and enforces real permissions. The server therefore **fails open**: it only hides a tool when confident your token cannot use it, and shows the tool whenever access is plausible. See [Limitations and Fail-Open Posture](#limitations-and-fail-open-posture).
8+
79
## How It Works
810

911
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.
@@ -76,6 +78,18 @@ If the server cannot fetch your token's scopes (e.g., network issues, rate limit
7678
WARN: failed to fetch token scopes, continuing without scope filtering
7779
```
7880

81+
## Limitations and Fail-Open Posture
82+
83+
Scope filtering is a **best-effort UX nicety**, not an authorization boundary. The GitHub API is the source of truth and enforces real permissions regardless of what the server shows. Because of this, the server is designed to **fail open**: it only hides a tool (or, for OAuth, issues a scope challenge) when it is confident the token cannot use it. When access is plausible, it prefers to show the tool and let the API decide. Filtering is also limited to classic PATs (`ghp_`) and is skipped entirely when scopes can't be fetched.
84+
85+
A tool's declared scopes are **all required** (logical AND), and each one may be satisfied directly or by an ancestor scope from the [hierarchy](#scope-hierarchy). However, some ways a tool can legitimately be used cannot be determined from OAuth scopes alone, so the server intentionally does not try to model them:
86+
87+
- **Sibling scopes outside the hierarchy.** The hierarchy only models *ancestor* substitution. For example, code scanning alerts on a **public** repository are readable with `public_repo`, which is a *sibling* of the declared `security_events` (both are children of `repo`), not an ancestor. Token expansion can't bridge siblings. Capturing this faithfully would require representing requirements as a list of OR-groups (groups AND-ed, members within a group OR-ed), e.g. code scanning = `security_events` OR `public_repo` OR `repo`. That model isn't implemented today; instead the server relies on the fail-open posture so these cases aren't wrongly hidden.
88+
- **Organization roles.** Roles such as *security manager* grant access orthogonally to OAuth scopes and are invisible to scope detection. A user may legitimately have access the server cannot see from scopes alone.
89+
- **Public vs. private repositories.** Whether a given scope suffices depends on the target repository's visibility, which isn't known at filter time.
90+
91+
In each of these cases the server errs toward showing the tool; if the token truly lacks access, the API returns the appropriate error.
92+
7993
## Classic vs Fine-Grained Personal Access Tokens
8094

8195
**Classic PATs** (`ghp_` prefix) support OAuth scopes and return them in the `X-OAuth-Scopes` header. Scope filtering works fully with these tokens.
@@ -92,7 +106,7 @@ WARN: failed to fetch token scopes, continuing without scope filtering
92106
|---------|-------|----------|
93107
| Missing expected tools | Token lacks required scope | [Edit your PAT's scopes](https://github.com/settings/tokens) in GitHub settings |
94108
| 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 |
109+
| "Insufficient permissions" errors | Tool visible but scope insufficient | Expected in some cases (fail-open, public/private ambiguity, org roles, or scope detection skipped). The API enforces the real boundary—grant the needed scope or access |
96110

97111
> **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.
98112

pkg/github/scope_filter.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ func onlyRequiresRepoScopes(requiredScopes []string) bool {
3737
// like we can with OAuth apps. Instead, we hide tools that require scopes
3838
// the token doesn't have.
3939
//
40+
// This is a best-effort UX filter, not an authorization boundary: the GitHub
41+
// API still enforces real permissions. It is gated to classic ghp_ PATs and is
42+
// skipped entirely when scopes can't be fetched, so the posture is to fail open
43+
// (prefer showing a tool when access is plausible). See docs/scope-filtering.md
44+
// for the known limitations (sibling scopes, org roles, repo visibility).
45+
//
4046
// This is the recommended way to filter tools for stdio servers where the
4147
// token is known at startup and won't change during the session.
4248
//

pkg/github/scope_filter_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ func TestCreateToolScopeFilter(t *testing.T) {
6363
RequiredScopes: []string{"repo", "read:org"},
6464
}
6565

66+
// Models security tools (code scanning etc.): read-only, single {security_events}.
67+
toolSecurityEvents := &inventory.ServerTool{
68+
Tool: mcp.Tool{
69+
Name: "list_code_scanning_alerts",
70+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
71+
},
72+
RequiredScopes: []string{"security_events"},
73+
}
74+
6675
tests := []struct {
6776
name string
6877
tokenScopes []string
@@ -147,6 +156,24 @@ func TestCreateToolScopeFilter(t *testing.T) {
147156
tool: toolRepoAndReadOrg,
148157
expected: true,
149158
},
159+
{
160+
name: "security tool: repo token satisfies security_events (neutral)",
161+
tokenScopes: []string{"repo"},
162+
tool: toolSecurityEvents,
163+
expected: true,
164+
},
165+
{
166+
name: "security tool: security_events token shows tool",
167+
tokenScopes: []string{"security_events"},
168+
tool: toolSecurityEvents,
169+
expected: true,
170+
},
171+
{
172+
name: "security tool: public_repo (sibling) does not show tool",
173+
tokenScopes: []string{"public_repo"},
174+
tool: toolSecurityEvents,
175+
expected: false,
176+
},
150177
}
151178

152179
for _, tt := range tests {

pkg/scopes/scopes.go

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,20 +172,29 @@ func expandScopeSet(scopes []string) map[string]bool {
172172
// requiredScopes. The requiredScopes are the literal scopes a tool declares and
173173
// they are all required: satisfaction is AND-of-ORs (conjunctive normal form).
174174
//
175-
// The AND is over the distinct required scopes. The OR is the hierarchy: each
176-
// required scope can be satisfied either directly or by a higher scope that
177-
// implicitly grants it. This is implemented by expanding the TOKEN downward
178-
// through the hierarchy (via expandScopeSet, e.g. repo -> public_repo and
179-
// admin:org -> read:org) and checking that every required scope is present in
180-
// that expanded set.
175+
// The AND is over the distinct required scopes. The OR, for each required
176+
// scope, is limited to the scope hierarchy: a required scope is satisfied
177+
// directly or by one of its ANCESTOR scopes that implicitly grants it. This is
178+
// implemented by expanding the TOKEN downward through the hierarchy (via
179+
// expandScopeSet, e.g. repo -> public_repo and admin:org -> read:org) and
180+
// checking that every required scope is present in that expanded set.
181181
//
182182
// An empty requiredScopes is always satisfied.
183183
//
184-
// Each required scope currently has exactly one satisfying alternative set (the
185-
// scope itself plus its hierarchy ancestors). If a tool ever needs true
186-
// arbitrary alternatives (e.g. "repo OR admin:org" for a single requirement),
187-
// the place to add it is a list-of-groups extension to RequiredScopes; that is
188-
// deliberately not built yet (YAGNI).
184+
// Scope filtering is a best-effort UX nicety for classic PATs, NOT an
185+
// authorization boundary: the GitHub API remains the source of truth, so the
186+
// intended posture is to fail open (only hide when confident the token cannot
187+
// work). See docs/scope-filtering.md.
188+
//
189+
// Limitation: the hierarchy only models ancestor substitution, not sibling
190+
// alternatives, so it does not capture every way a tool may be satisfied. For
191+
// example, reading code scanning alerts on a PUBLIC repo is possible with
192+
// public_repo, a sibling of the declared security_events (both children of
193+
// repo), which token expansion cannot bridge. Representing that faithfully
194+
// would require RequiredScopes to become a CNF list of OR-groups (groups
195+
// AND-ed, members within a group OR-ed), e.g. code scanning =
196+
// {security_events OR public_repo OR repo}. That structure is deliberately not
197+
// built yet (YAGNI).
189198
func HasRequiredScopes(tokenScopes []string, requiredScopes []string) bool {
190199
// No scopes required = always allowed
191200
if len(requiredScopes) == 0 {

pkg/scopes/scopes_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,24 @@ func TestHasRequiredScopes(t *testing.T) {
309309
requiredScopes: []string{"repo"},
310310
expected: false,
311311
},
312+
{
313+
name: "security tool: repo token satisfies security_events (neutral)",
314+
tokenScopes: []string{"repo"},
315+
requiredScopes: []string{"security_events"},
316+
expected: true,
317+
},
318+
{
319+
name: "security tool: security_events token satisfies security_events",
320+
tokenScopes: []string{"security_events"},
321+
requiredScopes: []string{"security_events"},
322+
expected: true,
323+
},
324+
{
325+
name: "security tool: public_repo (sibling) does NOT satisfy security_events",
326+
tokenScopes: []string{"public_repo"},
327+
requiredScopes: []string{"security_events"},
328+
expected: false,
329+
},
312330
{
313331
name: "user scope grants read:user",
314332
tokenScopes: []string{"user"},

0 commit comments

Comments
 (0)