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
118 changes: 118 additions & 0 deletions pkg/github/scope_assumptions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package github

import (
"context"
"testing"

ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// This file pins the deliberate, non-obvious assumptions baked into the OAuth
// scope model so they stay visible to developers revisiting them. Each test
// documents WHAT we assume, WHY, and the escape hatch if the assumption ever
// needs to change. If one of these fails, treat it as a prompt to make a
// conscious decision, not to silence the test.

// TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo encodes the
// intentional asymmetry between the two enforcement paths for a read-only tool
// whose only requirement is repo-ish access:
//
// - PAT filtering (CreateToolScopeFilter) is best-effort and keeps such tools
// VISIBLE even when the token advertises no scopes, because they still work
// against PUBLIC repositories and that access is useful.
// - The OAuth scope-challenge path (scopes.ToolScopeInfo.Satisfies /
// HasRequiredScopes) has NO such exception: it treats `repo` as genuinely
// required and will challenge the user to grant it.
//
// In other words: we'd rather show-and-let-the-API-decide for PATs, but
// proactively request the scope for OAuth where challenging is cheap and clean.
func TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo(t *testing.T) {
readOnlyRepoTool := &inventory.ServerTool{
Tool: mcp.Tool{
Name: "read_only_repo_tool",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
},
RequiredScopes: []string{"repo"},
}

// PAT side: shown even with no token scopes (public-repo access is useful).
patFilter := CreateToolScopeFilter([]string{})
shown, err := patFilter(context.Background(), readOnlyRepoTool)
require.NoError(t, err)
assert.True(t, shown, "PAT filtering should keep a read-only repo tool visible without any scope (public repo access)")

// OAuth side: the same requirement is NOT satisfied by an empty scope set,
// so the challenge middleware would request `repo`.
assert.False(t, scopes.HasRequiredScopes([]string{}, []string{"repo"}),
"OAuth challenge model must treat repo as required (no public-repo exception)")
info := &scopes.ToolScopeInfo{RequiredScopes: []string{"repo"}}
assert.False(t, info.Satisfies(),
"Satisfies must report unsatisfied for a repo tool with no granted scopes (triggers a challenge)")
assert.Equal(t, []string{"repo"}, info.MissingScopes(),
"the challenge should ask for exactly the missing repo scope")
}

// TestAssumption_WorkflowScopeIsGrantableButNeverChallenged encodes that the
// `workflow` scope is intentionally reachable only as an up-front grant, never
// via an on-demand scope challenge:
//
// - It IS advertised in oauth.SupportedScopes, so a classic PAT can carry it
// and the default OAuth login can request it up front.
// - But NO tool declares it as a required scope, so the challenge path can
// never ask for it on demand. (There is also deliberately no scopes.Workflow
// constant, so a tool cannot declare it via the typed API without someone
// first adding the constant.)
//
// This is a conscious risk-aversion choice: `workflow` grants control over
// GitHub Actions workflow files, so we don't auto-request it. If a tool ever
// genuinely needs it, the path is: add a scopes.Workflow constant, declare it on
// the tool, and accept that the challenge will then request `workflow` (it is
// already in SupportedScopes, so the mechanics work). This test will fail at
// that point to force that decision to be made deliberately.
func TestAssumption_WorkflowScopeIsGrantableButNeverChallenged(t *testing.T) {
assert.Contains(t, ghoauth.SupportedScopes, "workflow",
"workflow should remain a supported/grantable scope (PATs carry it; OAuth can request it up front)")

inv, err := NewInventory(translations.NullTranslationHelper).
WithToolsets([]string{"all"}).
Build()
require.NoError(t, err)

for _, tool := range inv.AllTools() {
assert.NotContains(t, tool.RequiredScopes, "workflow",
"tool %q declares the workflow scope as required; the OAuth challenge path would then request it. "+
"That is an intentional escape hatch — update this test and confirm the risk is acceptable.", tool.Tool.Name)
}
}

// TestAssumption_MultiScopeRequirementsAreTreatedAsAND encodes that when a tool
// declares more than one required scope we treat them as a conjunction (ALL
// required), because the declaration ([]scopes.Scope) cannot express "any of".
// We cannot distinguish a genuine hard-AND from a genuine hard-OR, so we
// conservatively require all of them. Hierarchy substitution still applies, so
// an ancestor scope satisfies a required descendant.
//
// If a real OR requirement ever appears, the escape hatch is to extend the
// model to OR-groups (AND across groups, OR within a group); see
// scopes.HasRequiredScopes. Until then, AND is the deliberate default.
func TestAssumption_MultiScopeRequirementsAreTreatedAsAND(t *testing.T) {
required := []string{"repo", "read:org"}

// AND: one of the two scopes is not enough.
assert.False(t, scopes.HasRequiredScopes([]string{"repo"}, required),
"a token with only repo must NOT satisfy a {repo, read:org} tool (treated as AND, not OR)")

// Both scopes present satisfies the conjunction.
assert.True(t, scopes.HasRequiredScopes([]string{"repo", "read:org"}, required),
"a token holding both required scopes satisfies the conjunction")

// Hierarchy substitution still applies on top of AND: admin:org grants read:org.
assert.True(t, scopes.HasRequiredScopes([]string{"repo", "admin:org"}, required),
"an ancestor scope (admin:org) still satisfies a required descendant (read:org) under AND")
}
34 changes: 25 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,16 @@ 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.
//
// Note: this public-repo exception is PAT-only. The OAuth scope-challenge
// path (scopes.ToolScopeInfo.Satisfies) has no equivalent and will still
// challenge for `repo`. That asymmetry is intentional; see
// TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo.
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
}
}
Loading
Loading