Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -922,13 +922,13 @@ The following sets of tools are available:
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)

- **list_issue_fields** - List issue fields
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Required OAuth Scopes**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required)
- `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional)

- **list_issue_types** - List available issue types
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Required OAuth Scopes**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `owner`: The account owner of the repository or organization. (string, required)
- `repo`: The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly. (string, optional)
Expand Down
6 changes: 5 additions & 1 deletion cmd/github-mcp-server/feature_flag_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,14 @@ func generateFlaggedToolsDoc(flags []string, emptyMessage string) string {
if !hasAny {
return emptyMessage
}
// Clarify scope semantics for the rendered tools: every listed required
// scope is needed (AND), and a higher scope in the hierarchy also satisfies
// a required scope.
preamble := "> **OAuth scopes:** all listed required scopes are needed (AND). A higher scope in the hierarchy (e.g. `admin:org` for `read:org`, `repo` for `public_repo`) also satisfies a required scope.\n\n"
// Leading/trailing newlines around the body produce blank lines between
// our content and the surrounding marker comments, so the trailing comment
// doesn't get absorbed into the final list item by markdown renderers.
return "\n" + strings.TrimSuffix(buf.String(), "\n") + "\n"
return "\n" + preamble + strings.TrimSuffix(buf.String(), "\n") + "\n"
}

// flaggedToolDiff returns the tools whose definition (input schema or meta)
Expand Down
12 changes: 4 additions & 8 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {

// OAuth scopes if present
if len(tool.RequiredScopes) > 0 {
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
// so when multiple required scopes are listed, render them as alternatives
// rather than implying all are required.
// All listed required scopes are needed (AND). A higher scope in the
// hierarchy also satisfies a required scope (see scopes.HasRequiredScopes).
// AcceptedScopes below is non-authoritative display metadata.
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
if len(tool.RequiredScopes) > 1 {
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
} else {
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
}
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)

// Only show accepted scopes if they differ from required scopes
if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) {
Expand Down
4 changes: 3 additions & 1 deletion docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ runtime behavior (such as output formatting) won't appear here.

<!-- START AUTOMATED FEATURE FLAG TOOLS -->

> **OAuth scopes:** all listed required scopes are needed (AND). A higher scope in the hierarchy (e.g. `admin:org` for `read:org`, `repo` for `public_repo`) also satisfies a required scope.

### `remote_mcp_ui_apps`

- **create_pull_request** - Open new pull request
Expand Down Expand Up @@ -74,7 +76,7 @@ runtime behavior (such as output formatting) won't appear here.
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)

- **ui_get** - Get UI data
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Required OAuth Scopes**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `method`: The type of data to fetch (string, required)
- `owner`: Repository owner (required for all methods) (string, required)
Expand Down
4 changes: 3 additions & 1 deletion docs/insiders-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ The list below is generated from the Go source. It covers tool **inventory and s

<!-- START AUTOMATED INSIDERS TOOLS -->

> **OAuth scopes:** all listed required scopes are needed (AND). A higher scope in the hierarchy (e.g. `admin:org` for `read:org`, `repo` for `public_repo`) also satisfies a required scope.

### `remote_mcp_ui_apps`

- **create_pull_request** - Open new pull request
Expand Down Expand Up @@ -68,7 +70,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)

- **ui_get** - Get UI data
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Required OAuth Scopes**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `method`: The type of data to fetch (string, required)
- `owner`: Repository owner (required for all methods) (string, required)
Expand Down
17 changes: 16 additions & 1 deletion docs/scope-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The GitHub MCP Server automatically filters available tools based on your classi

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

> **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).

## How It Works

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

## Limitations and Fail-Open Posture

Scope filtering is a **best-effort UX convenience** for classic PATs (`ghp_`) only. It is **NOT an authorization boundary** — the GitHub API is the source of truth and enforces real permissions regardless of what the server shows. The server therefore **fails open**: when access is plausible but unprovable at filter/challenge time, the tool is shown rather than hidden.

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). Some ways a tool can legitimately be used cannot be determined from OAuth scopes alone, so the scope model intentionally does not fully capture them:

- **Public vs. private repositories.** Which scope suffices can depend on the target repository, which isn't known when tools are filtered. For example, code scanning alerts on **public** repos are readable with `public_repo`, while **private** repos need `security_events` (or `repo`).
- **Sibling-OR alternatives.** `security_events` and `public_repo` are *siblings* under `repo` (not parent/child), so token hierarchy expansion can't treat one as satisfying the other. A `public_repo`-only token may therefore have the security tools (code scanning, secret scanning, Dependabot, security advisories) hidden even though it could read public-repo data.
- **Organization roles.** A *security manager* (or similar) org role grants access orthogonally to OAuth scopes and is invisible to scope filtering.
- **Other token types.** Fine-grained PATs, OAuth, and GitHub App tokens use different permission models; filtering is skipped for them entirely (gated to `ghp_`), which is fail-open by design.

These cases are deferred to runtime API enforcement. If precise sibling-OR modeling is ever needed, the extension point is making the required scopes a list of OR-groups (AND across groups, OR within a group) — deliberately not built yet.

## Classic vs Fine-Grained Personal Access Tokens

**Classic PATs** (`ghp_` prefix) support OAuth scopes and return them in the `X-OAuth-Scopes` header. Scope filtering works fully with these tokens.
Expand All @@ -92,7 +107,7 @@ WARN: failed to fetch token scopes, continuing without scope filtering
|---------|-------|----------|
| Missing expected tools | Token lacks required scope | [Edit your PAT's scopes](https://github.com/settings/tokens) in GitHub settings |
| All tools visible despite limited PAT | Scope detection failed | Check logs for warnings about scope fetching |
| "Insufficient permissions" errors | Tool visible but scope insufficient | This shouldn't happen with scope filtering; report as bug |
| "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 |

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

Expand Down
29 changes: 20 additions & 9 deletions pkg/github/scope_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ var repoScopesSet = map[string]bool{
string(scopes.PublicRepo): true,
}

// onlyRequiresRepoScopes returns true if all of the tool's accepted scopes
// onlyRequiresRepoScopes returns true if all of the tool's required scopes
// are repo-related scopes (repo, public_repo). Such tools work on public
// repositories without needing any scope.
func onlyRequiresRepoScopes(acceptedScopes []string) bool {
if len(acceptedScopes) == 0 {
func onlyRequiresRepoScopes(requiredScopes []string) bool {
if len(requiredScopes) == 0 {
return false
}
for _, scope := range acceptedScopes {
for _, scope := range requiredScopes {
if !repoScopesSet[scope] {
return false
}
Expand All @@ -37,13 +37,23 @@ func onlyRequiresRepoScopes(acceptedScopes []string) bool {
// like we can with OAuth apps. Instead, we hide tools that require scopes
// the token doesn't have.
//
// This is a best-effort UX filter, not an authorization boundary: the GitHub
// API still enforces real permissions. It is gated to classic ghp_ PATs and is
// skipped entirely when scopes can't be fetched, so the posture is to fail open
// (prefer showing a tool when access is plausible). See docs/scope-filtering.md
// for the known limitations (sibling scopes, org roles, repo visibility).
//
// This is the recommended way to filter tools for stdio servers where the
// token is known at startup and won't change during the session.
//
// The filter returns true (include tool) if:
// - The tool has no scope requirements (AcceptedScopes is empty)
// - The tool has no scope requirements (RequiredScopes is empty)
// - The tool is read-only and only requires repo/public_repo scopes (works on public repos)
// - The token has at least one of the tool's accepted scopes
// - The token satisfies ALL of the tool's required scopes (AND-of-ORs, where
// each required scope may be met directly or by a higher scope)
//
// RequiredScopes is the single source of truth here; AcceptedScopes is
// display-only metadata and is intentionally not consulted.
//
// Example usage:
//
Expand All @@ -55,10 +65,11 @@ func onlyRequiresRepoScopes(acceptedScopes []string) bool {
// inventory := github.NewInventory(t).WithFilter(filter).Build()
func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter {
return func(_ context.Context, tool *inventory.ServerTool) (bool, error) {
// Read-only tools requiring only repo/public_repo work on public repos without any scope
if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.AcceptedScopes) {
// Read-only tools requiring only repo/public_repo work on public repos without any scope.
// Tools that also require a non-repo scope (e.g. {repo, read:org}) fall through to the AND check.
if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.RequiredScopes) {
return true, nil
}
return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil
return scopes.HasRequiredScopes(tokenScopes, tool.RequiredScopes), nil
}
}
93 changes: 72 additions & 21 deletions pkg/github/scope_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,65 @@ import (
)

func TestCreateToolScopeFilter(t *testing.T) {
// Create test tools with various scope requirements
// Create test tools with various scope requirements.
// RequiredScopes is the single source of truth for filtering.
toolNoScopes := &inventory.ServerTool{
Tool: mcp.Tool{Name: "no_scopes_tool"},
AcceptedScopes: nil,
RequiredScopes: nil,
}

toolEmptyScopes := &inventory.ServerTool{
Tool: mcp.Tool{Name: "empty_scopes_tool"},
AcceptedScopes: []string{},
RequiredScopes: []string{},
}

toolRepoScope := &inventory.ServerTool{
Tool: mcp.Tool{Name: "repo_tool"},
AcceptedScopes: []string{"repo"},
RequiredScopes: []string{"repo"},
}

toolRepoScopeReadOnly := &inventory.ServerTool{
Tool: mcp.Tool{
Name: "repo_tool_readonly",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
},
AcceptedScopes: []string{"repo"},
RequiredScopes: []string{"repo"},
}

toolPublicRepoScope := &inventory.ServerTool{
Tool: mcp.Tool{Name: "public_repo_tool"},
AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted
RequiredScopes: []string{"public_repo"},
}

toolPublicRepoScopeReadOnly := &inventory.ServerTool{
Tool: mcp.Tool{
Name: "public_repo_tool_readonly",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
},
AcceptedScopes: []string{"public_repo", "repo"},
RequiredScopes: []string{"public_repo"},
}

toolGistScope := &inventory.ServerTool{
Tool: mcp.Tool{Name: "gist_tool"},
AcceptedScopes: []string{"gist"},
RequiredScopes: []string{"gist"},
}

toolMultiScope := &inventory.ServerTool{
Tool: mcp.Tool{Name: "multi_scope_tool"},
AcceptedScopes: []string{"repo", "admin:org"},
// Models ui_get / list_issue_fields: read-only, but requires repo AND read:org.
toolRepoAndReadOrg := &inventory.ServerTool{
Tool: mcp.Tool{
Name: "ui_get",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
},
RequiredScopes: []string{"repo", "read:org"},
}

// Models security tools (code scanning etc.): read-only, single {security_events}.
toolSecurityEvents := &inventory.ServerTool{
Tool: mcp.Tool{
Name: "list_code_scanning_alerts",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
},
RequiredScopes: []string{"security_events"},
}

tests := []struct {
Expand Down Expand Up @@ -100,12 +114,6 @@ func TestCreateToolScopeFilter(t *testing.T) {
tool: toolGistScope,
expected: false,
},
{
name: "token with one of multiple accepted scopes can see tool",
tokenScopes: []string{"admin:org"},
tool: toolMultiScope,
expected: true,
},
{
name: "empty token scopes cannot see scoped tools",
tokenScopes: []string{},
Expand All @@ -130,6 +138,42 @@ func TestCreateToolScopeFilter(t *testing.T) {
tool: toolPublicRepoScope,
expected: true,
},
{
name: "AND: repo-only classic PAT hides {repo, read:org} tool",
tokenScopes: []string{"repo"},
tool: toolRepoAndReadOrg,
expected: false,
},
{
name: "AND: {repo, read:org} token shows {repo, read:org} tool",
tokenScopes: []string{"repo", "read:org"},
tool: toolRepoAndReadOrg,
expected: true,
},
{
name: "AND: {repo, admin:org} token shows {repo, read:org} tool via hierarchy",
tokenScopes: []string{"repo", "admin:org"},
tool: toolRepoAndReadOrg,
expected: true,
},
{
name: "security tool: repo token satisfies security_events (neutral)",
tokenScopes: []string{"repo"},
tool: toolSecurityEvents,
expected: true,
},
{
name: "security tool: security_events token shows tool",
tokenScopes: []string{"security_events"},
tool: toolSecurityEvents,
expected: true,
},
{
name: "security tool: public_repo (sibling) does not show tool",
tokenScopes: []string{"public_repo"},
tool: toolSecurityEvents,
expected: false,
},
}

for _, tt := range tests {
Expand All @@ -149,17 +193,23 @@ func TestCreateToolScopeFilter_Integration(t *testing.T) {
{
Tool: mcp.Tool{Name: "public_tool"},
Toolset: inventory.ToolsetMetadata{ID: "test"},
AcceptedScopes: nil, // No scopes required
RequiredScopes: nil, // No scopes required
},
{
Tool: mcp.Tool{Name: "repo_tool"},
Toolset: inventory.ToolsetMetadata{ID: "test"},
AcceptedScopes: []string{"repo"},
RequiredScopes: []string{"repo"},
},
{
Tool: mcp.Tool{Name: "gist_tool"},
Toolset: inventory.ToolsetMetadata{ID: "test"},
AcceptedScopes: []string{"gist"},
RequiredScopes: []string{"gist"},
},
{
// Requires repo AND read:org; hidden for a {repo}-only token.
Tool: mcp.Tool{Name: "list_issue_fields"},
Toolset: inventory.ToolsetMetadata{ID: "test"},
RequiredScopes: []string{"repo", "read:org"},
},
}

Expand All @@ -177,7 +227,7 @@ func TestCreateToolScopeFilter_Integration(t *testing.T) {
// Get available tools
availableTools := inv.AvailableTools(context.Background())

// Should see public_tool and repo_tool, but not gist_tool
// Should see public_tool and repo_tool, but not gist_tool or list_issue_fields
assert.Len(t, availableTools, 2)

toolNames := make([]string, len(availableTools))
Expand All @@ -188,4 +238,5 @@ func TestCreateToolScopeFilter_Integration(t *testing.T) {
assert.Contains(t, toolNames, "public_tool")
assert.Contains(t, toolNames, "repo_tool")
assert.NotContains(t, toolNames, "gist_tool")
assert.NotContains(t, toolNames, "list_issue_fields")
}
Loading
Loading