Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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`).
Comment thread
SamMorrowDrums marked this conversation as resolved.
Outdated
- **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.
Comment thread
SamMorrowDrums marked this conversation as resolved.
Outdated

## 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 |
Comment thread
SamMorrowDrums marked this conversation as resolved.
Outdated

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