From b40df1facdccc66c91ed594dc30d2d8760cb33b6 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 13:55:28 +0100 Subject: [PATCH 01/13] Add PAT scope filtering for stdio server Add the ability to filter tools based on token scopes for PAT users. This uses an HTTP HEAD request to GitHub's API to discover token scopes. New components: - pkg/scopes/filter.go: HasRequiredScopes checks if scopes satisfy tool requirements - pkg/scopes/fetcher.go: FetchTokenScopes gets scopes via HTTP HEAD to GitHub API - pkg/github/scope_filter.go: CreateScopeFilter creates inventory.ToolFilter Integration: - Add --filter-by-scope flag to stdio command (disabled by default) - When enabled, fetches token scopes on startup - Tools requiring unavailable scopes are hidden from tool list - Gracefully continues without filtering if scope fetch fails (logs warning) This allows the OSS server to have similar scope-based tool visibility as the remote server, and the filter logic can be reused by remote server. --- cmd/github-mcp-server/main.go | 3 + internal/ghmcp/server.go | 53 +++++++- pkg/github/scope_filter.go | 36 ++++++ pkg/github/scope_filter_test.go | 162 ++++++++++++++++++++++++ pkg/scopes/fetcher.go | 125 +++++++++++++++++++ pkg/scopes/fetcher_test.go | 214 ++++++++++++++++++++++++++++++++ pkg/scopes/filter.go | 9 ++ pkg/scopes/scopes.go | 44 +++++++ pkg/scopes/scopes_test.go | 180 +++++++++++++++++++++++++++ 9 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 pkg/github/scope_filter.go create mode 100644 pkg/github/scope_filter_test.go create mode 100644 pkg/scopes/fetcher.go create mode 100644 pkg/scopes/fetcher_test.go create mode 100644 pkg/scopes/filter.go diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4e..f5bf16a1a 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -84,6 +84,7 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, + EnableScopeFiltering: viper.GetBool("enable-scope-filtering"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -109,6 +110,7 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + rootCmd.PersistentFlags().Bool("enable-scope-filtering", false, "Filter tools based on the token's OAuth scopes") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -123,6 +125,7 @@ func init() { _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("enable-scope-filtering", rootCmd.PersistentFlags().Lookup("enable-scope-filtering")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9b..7da2f825f 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -19,6 +19,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -67,6 +68,11 @@ type MCPServerConfig struct { Logger *slog.Logger // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration + + // TokenScopes contains the OAuth scopes available to the token. + // When non-nil, tools requiring scopes not in this list will be hidden. + // This is used for PAT scope filtering where we can't issue scope challenges. + TokenScopes []string } // githubClients holds all the GitHub API clients created for a server instance. @@ -211,13 +217,19 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { }) // Build and register the tool/resource/prompt inventory - inventory := github.NewInventory(cfg.Translator). + inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(enabledToolsets). WithTools(github.CleanTools(cfg.EnabledTools)). - WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). - Build() + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) + + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + } + + inventory := inventoryBuilder.Build() if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) @@ -312,6 +324,11 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + + // EnableScopeFiltering enables PAT scope-based tool filtering. + // When true, the server will fetch the token's OAuth scopes at startup + // and hide tools that require scopes the token doesn't have. + EnableScopeFiltering bool } // RunStdioServer is not concurrent safe. @@ -336,7 +353,19 @@ func RunStdioServer(cfg StdioServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode, "scopeFiltering", cfg.EnableScopeFiltering) + + // Fetch token scopes if scope filtering is enabled + var tokenScopes []string + if cfg.EnableScopeFiltering { + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + } + } ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, @@ -352,6 +381,7 @@ func RunStdioServer(cfg StdioServerConfig) error { LockdownMode: cfg.LockdownMode, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, + TokenScopes: tokenScopes, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) @@ -636,3 +666,18 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g } } } + +// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. +// It constructs the appropriate API host URL based on the configured host. +func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { + apiHost, err := parseAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) + } + + fetcher := scopes.NewFetcher(scopes.FetcherOptions{ + APIHost: apiHost.baseRESTURL.String(), + }) + + return fetcher.FetchTokenScopes(ctx, token) +} diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go new file mode 100644 index 000000000..b1aa77c85 --- /dev/null +++ b/pkg/github/scope_filter.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" +) + +// CreateToolScopeFilter creates an inventory.ToolFilter that filters tools +// based on the token's OAuth scopes. +// +// For PATs (Personal Access Tokens), we cannot issue OAuth scope challenges +// like we can with OAuth apps. Instead, we hide tools that require scopes +// the token doesn't have. +// +// 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 token has at least one of the tool's accepted scopes +// +// Example usage: +// +// tokenScopes, err := scopes.FetchTokenScopes(ctx, token) +// if err != nil { +// // Handle error - maybe skip filtering +// } +// filter := github.CreateToolScopeFilter(tokenScopes) +// inventory := github.NewInventory(t).WithFilter(filter).Build() +func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter { + return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil + } +} diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go new file mode 100644 index 000000000..48eb52aa0 --- /dev/null +++ b/pkg/github/scope_filter_test.go @@ -0,0 +1,162 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateToolScopeFilter(t *testing.T) { + // Create test tools with various scope requirements + toolNoScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "no_scopes_tool"}, + AcceptedScopes: nil, + } + + toolEmptyScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "empty_scopes_tool"}, + AcceptedScopes: []string{}, + } + + toolRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "repo_tool"}, + AcceptedScopes: []string{"repo"}, + } + + toolPublicRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "public_repo_tool"}, + AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted + } + + toolGistScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "gist_tool"}, + AcceptedScopes: []string{"gist"}, + } + + toolMultiScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "multi_scope_tool"}, + AcceptedScopes: []string{"repo", "admin:org"}, + } + + tests := []struct { + name string + tokenScopes []string + tool *inventory.ServerTool + expected bool + }{ + { + name: "tool with no scopes is always visible", + tokenScopes: []string{}, + tool: toolNoScopes, + expected: true, + }, + { + name: "tool with empty scopes is always visible", + tokenScopes: []string{"repo"}, + tool: toolEmptyScopes, + expected: true, + }, + { + name: "token with exact scope can see tool", + tokenScopes: []string{"repo"}, + tool: toolRepoScope, + expected: true, + }, + { + name: "token with parent scope can see child-scoped tool", + tokenScopes: []string{"repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + { + name: "token missing required scope cannot see tool", + tokenScopes: []string{"gist"}, + tool: toolRepoScope, + expected: false, + }, + { + name: "token with unrelated scope cannot see tool", + tokenScopes: []string{"repo"}, + 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{}, + tool: toolRepoScope, + expected: false, + }, + { + name: "token with multiple scopes where one matches", + tokenScopes: []string{"gist", "repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := CreateToolScopeFilter(tt.tokenScopes) + result, err := filter(context.Background(), tt.tool) + + require.NoError(t, err) + assert.Equal(t, tt.expected, result, "filter result should match expected") + }) + } +} + +func TestCreateToolScopeFilter_Integration(t *testing.T) { + // Test integration with inventory builder + tools := []inventory.ServerTool{ + { + Tool: mcp.Tool{Name: "public_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: nil, // No scopes required + }, + { + Tool: mcp.Tool{Name: "repo_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"repo"}, + }, + { + Tool: mcp.Tool{Name: "gist_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"gist"}, + }, + } + + // Create filter for token with only "repo" scope + filter := CreateToolScopeFilter([]string{"repo"}) + + // Build inventory with the filter + inv := inventory.NewBuilder(). + SetTools(tools). + WithToolsets([]string{"test"}). + WithFilter(filter). + Build() + + // Get available tools + availableTools := inv.AvailableTools(context.Background()) + + // Should see public_tool and repo_tool, but not gist_tool + assert.Len(t, availableTools, 2) + + toolNames := make([]string, len(availableTools)) + for i, tool := range availableTools { + toolNames[i] = tool.Tool.Name + } + + assert.Contains(t, toolNames, "public_tool") + assert.Contains(t, toolNames, "repo_tool") + assert.NotContains(t, toolNames, "gist_tool") +} diff --git a/pkg/scopes/fetcher.go b/pkg/scopes/fetcher.go new file mode 100644 index 000000000..48e000179 --- /dev/null +++ b/pkg/scopes/fetcher.go @@ -0,0 +1,125 @@ +package scopes + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. +const OAuthScopesHeader = "X-OAuth-Scopes" + +// DefaultFetchTimeout is the default timeout for scope fetching requests. +const DefaultFetchTimeout = 10 * time.Second + +// FetcherOptions configures the scope fetcher. +type FetcherOptions struct { + // HTTPClient is the HTTP client to use for requests. + // If nil, a default client with DefaultFetchTimeout is used. + HTTPClient *http.Client + + // APIHost is the GitHub API host (e.g., "https://api.github.com"). + // Defaults to "https://api.github.com" if empty. + APIHost string +} + +// Fetcher retrieves token scopes from GitHub's API. +// It uses an HTTP HEAD request to minimize bandwidth since we only need headers. +type Fetcher struct { + client *http.Client + apiHost string +} + +// NewFetcher creates a new scope fetcher with the given options. +func NewFetcher(opts FetcherOptions) *Fetcher { + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: DefaultFetchTimeout} + } + + apiHost := opts.APIHost + if apiHost == "" { + apiHost = "https://api.github.com" + } + + return &Fetcher{ + client: client, + apiHost: apiHost, + } +} + +// FetchTokenScopes retrieves the OAuth scopes for a token by making an HTTP HEAD +// request to the GitHub API and parsing the X-OAuth-Scopes header. +// +// Returns: +// - []string: List of scopes (empty if no scopes or fine-grained PAT) +// - error: Any HTTP or parsing error +// +// Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty +// slice is returned for those tokens. +func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + // Use a lightweight endpoint that requires authentication + endpoint, err := url.JoinPath(f.apiHost, "/") + if err != nil { + return nil, fmt.Errorf("failed to construct API URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch scopes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid or expired token") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return ParseScopeHeader(resp.Header.Get(OAuthScopesHeader)), nil +} + +// ParseScopeHeader parses the X-OAuth-Scopes header value into a list of scopes. +// The header contains comma-separated scope names. +// Returns an empty slice for empty or missing header. +func ParseScopeHeader(header string) []string { + if header == "" { + return []string{} + } + + parts := strings.Split(header, ",") + scopes := make([]string, 0, len(parts)) + for _, part := range parts { + scope := strings.TrimSpace(part) + if scope != "" { + scopes = append(scopes, scope) + } + } + return scopes +} + +// FetchTokenScopes is a convenience function that creates a default fetcher +// and fetches the token scopes. +func FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + return NewFetcher(FetcherOptions{}).FetchTokenScopes(ctx, token) +} + +// FetchTokenScopesWithHost is a convenience function that creates a fetcher +// for a specific API host and fetches the token scopes. +func FetchTokenScopesWithHost(ctx context.Context, token, apiHost string) ([]string, error) { + return NewFetcher(FetcherOptions{APIHost: apiHost}).FetchTokenScopes(ctx, token) +} diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go new file mode 100644 index 000000000..13feab5b0 --- /dev/null +++ b/pkg/scopes/fetcher_test.go @@ -0,0 +1,214 @@ +package scopes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScopeHeader(t *testing.T) { + tests := []struct { + name string + header string + expected []string + }{ + { + name: "empty header", + header: "", + expected: []string{}, + }, + { + name: "single scope", + header: "repo", + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + header: "repo, user, gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with extra whitespace", + header: " repo , user , gist ", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes without spaces", + header: "repo,user,gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with colons", + header: "read:org, write:org, admin:org", + expected: []string{"read:org", "write:org", "admin:org"}, + }, + { + name: "empty parts are filtered", + header: "repo,,gist", + expected: []string{"repo", "gist"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseScopeHeader(tt.header) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFetcher_FetchTokenScopes(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + expectedScopes []string + expectError bool + errorContains string + }{ + { + name: "successful fetch with multiple scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo, user, gist") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo", "user", "gist"}, + expectError: false, + }, + { + name: "successful fetch with single scope", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "fine-grained PAT returns empty scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + // Fine-grained PATs don't return X-OAuth-Scopes + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{}, + expectError: false, + }, + { + name: "unauthorized token", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + expectError: true, + errorContains: "invalid or expired token", + }, + { + name: "server error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + expectError: true, + errorContains: "unexpected status code: 500", + }, + { + name: "verifies authorization header is set", + handler: func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "verifies request method is HEAD", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token") + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedScopes, scopes) + } + }) + } +} + +func TestFetcher_DefaultOptions(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{}) + + // Verify default API host is set + assert.Equal(t, "https://api.github.com", fetcher.apiHost) + + // Verify default HTTP client is set with timeout + assert.NotNil(t, fetcher.client) + assert.Equal(t, DefaultFetchTimeout, fetcher.client.Timeout) +} + +func TestFetcher_CustomHTTPClient(t *testing.T) { + customClient := &http.Client{Timeout: 5 * time.Second} + + fetcher := NewFetcher(FetcherOptions{ + HTTPClient: customClient, + }) + + assert.Equal(t, customClient, fetcher.client) +} + +func TestFetcher_CustomAPIHost(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{ + APIHost: "https://api.github.enterprise.com", + }) + + assert.Equal(t, "https://api.github.enterprise.com", fetcher.apiHost) +} + +func TestFetcher_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := fetcher.FetchTokenScopes(ctx, "test-token") + require.Error(t, err) +} diff --git a/pkg/scopes/filter.go b/pkg/scopes/filter.go new file mode 100644 index 000000000..143b736e2 --- /dev/null +++ b/pkg/scopes/filter.go @@ -0,0 +1,9 @@ +// Package scopes provides OAuth scope checking utilities for GitHub MCP Server. +// +// This file contains utilities for filtering tools based on token scopes. +// For PATs, we cannot issue OAuth scope challenges, so we hide tools that +// require scopes the token doesn't have. +// +// The CreateToolScopeFilter function should be called from the github package +// or other packages that can import inventory to create the actual filter. +package scopes diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 0be6ca32b..a9b06e988 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -148,3 +148,47 @@ func ExpandScopes(required ...Scope) []string { sort.Strings(result) return result } + +// expandScopeSet returns a set of all scopes granted by the given scopes, +// including child scopes from the hierarchy. +// For example, if "repo" is provided, the result includes "repo", "public_repo", +// and "security_events" since "repo" grants access to those child scopes. +func expandScopeSet(scopes []string) map[string]bool { + expanded := make(map[string]bool, len(scopes)) + for _, scope := range scopes { + expanded[scope] = true + // Add child scopes granted by this scope + if children, ok := ScopeHierarchy[Scope(scope)]; ok { + for _, child := range children { + expanded[string(child)] = true + } + } + } + return expanded +} + +// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement. +// A tool's acceptedScopes includes both the required scopes AND parent scopes +// that implicitly grant the required permissions (via ExpandScopes). +// +// For PAT filtering: if ANY of the acceptedScopes are granted by the token +// (directly or via scope hierarchy), the tool should be visible. +// +// Returns true if the tool should be visible to the token holder. +func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool { + // No scopes required = always allowed + if len(acceptedScopes) == 0 { + return true + } + + // Expand token scopes to include child scopes they grant + grantedScopes := expandScopeSet(tokenScopes) + + // Check if any accepted scope is granted by the token + for _, accepted := range acceptedScopes { + if grantedScopes[accepted] { + return true + } + } + return false +} diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 8ef29a115..b8e0d8e42 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -150,3 +150,183 @@ func TestScopeHierarchy(t *testing.T) { assert.Contains(t, ScopeHierarchy[User], ReadUser) assert.Contains(t, ScopeHierarchy[User], UserEmail) } + +func TestExpandScopeSet(t *testing.T) { + tests := []struct { + name string + scopes []string + expected map[string]bool + }{ + { + name: "empty scopes", + scopes: []string{}, + expected: map[string]bool{}, + }, + { + name: "repo expands to include public_repo and security_events", + scopes: []string{"repo"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + }, + }, + { + name: "admin:org expands to include write:org and read:org", + scopes: []string{"admin:org"}, + expected: map[string]bool{ + "admin:org": true, + "write:org": true, + "read:org": true, + }, + }, + { + name: "write:org expands to include read:org", + scopes: []string{"write:org"}, + expected: map[string]bool{ + "write:org": true, + "read:org": true, + }, + }, + { + name: "user expands to include read:user and user:email", + scopes: []string{"user"}, + expected: map[string]bool{ + "user": true, + "read:user": true, + "user:email": true, + }, + }, + { + name: "scope without children stays as-is", + scopes: []string{"gist"}, + expected: map[string]bool{ + "gist": true, + }, + }, + { + name: "multiple scopes combine correctly", + scopes: []string{"repo", "gist"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + "gist": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandScopeSet(tt.scopes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasRequiredScopes(t *testing.T) { + tests := []struct { + name string + tokenScopes []string + acceptedScopes []string + expected bool + }{ + { + name: "no accepted scopes - always allowed", + tokenScopes: []string{}, + acceptedScopes: []string{}, + expected: true, + }, + { + name: "nil accepted scopes - always allowed", + tokenScopes: []string{"repo"}, + acceptedScopes: nil, + expected: true, + }, + { + name: "token has exact required scope", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo"}, + expected: true, + }, + { + name: "token has parent scope that grants access", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "token has parent scope for security_events", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"security_events"}, + expected: true, + }, + { + name: "token has admin:org which grants read:org", + tokenScopes: []string{"admin:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token has write:org which grants read:org", + tokenScopes: []string{"write:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token missing required scope", + tokenScopes: []string{"gist"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "token has child but not parent - fails", + tokenScopes: []string{"public_repo"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "multiple token scopes - one matches", + tokenScopes: []string{"gist", "repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "multiple accepted scopes - token has one", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo", "admin:org"}, + expected: true, + }, + { + name: "empty token scopes - fails when scopes required", + tokenScopes: []string{}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "user scope grants read:user", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"read:user"}, + expected: true, + }, + { + name: "user scope grants user:email", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"user:email"}, + expected: true, + }, + { + name: "write:packages grants read:packages", + tokenScopes: []string{"write:packages"}, + acceptedScopes: []string{"read:packages"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasRequiredScopes(tt.tokenScopes, tt.acceptedScopes) + assert.Equal(t, tt.expected, result) + }) + } +} From e3548f1503626419b654070c3e10e3cbe04a961a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:02:31 +0100 Subject: [PATCH 02/13] Enable scope filtering by default --- cmd/github-mcp-server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index f5bf16a1a..528dd976a 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -110,7 +110,7 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") - rootCmd.PersistentFlags().Bool("enable-scope-filtering", false, "Filter tools based on the token's OAuth scopes") + rootCmd.PersistentFlags().Bool("enable-scope-filtering", true, "Filter tools based on the token's OAuth scopes") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) From 7bf3cdbe7a91739a8ed5279af8107459e07fa8ff Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:10:35 +0100 Subject: [PATCH 03/13] Make scope filtering always enabled (remove flag) Scope filtering is now a built-in feature rather than a configurable option. The server automatically fetches token scopes at startup and filters tools accordingly. If scope detection fails, it logs a warning and continues with all tools available. --- cmd/github-mcp-server/main.go | 3 -- docs/scope-filtering.md | 89 +++++++++++++++++++++++++++++++++++ docs/server-configuration.md | 11 +++++ internal/ghmcp/server.go | 23 ++++----- 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 docs/scope-filtering.md diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 528dd976a..cfb68be4e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -84,7 +84,6 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, - EnableScopeFiltering: viper.GetBool("enable-scope-filtering"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -110,7 +109,6 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") - rootCmd.PersistentFlags().Bool("enable-scope-filtering", true, "Filter tools based on the token's OAuth scopes") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -125,7 +123,6 @@ func init() { _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) - _ = viper.BindPFlag("enable-scope-filtering", rootCmd.PersistentFlags().Lookup("enable-scope-filtering")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md new file mode 100644 index 000000000..888a0e1f8 --- /dev/null +++ b/docs/scope-filtering.md @@ -0,0 +1,89 @@ +# OAuth Scope Filtering + +The GitHub MCP Server automatically filters available tools based on your 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. + +## How It Works + +When the server starts, 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. + +**Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes. + +## Checking Your Token's Scopes + +To see what scopes your token has, you can run: + +```bash +curl -sI -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ + https://api.github.com/user | grep -i x-oauth-scopes +``` + +Example output: +``` +x-oauth-scopes: delete_repo, gist, read:org, repo +``` + +## Scopes and Tools + +The following table shows which OAuth scopes are required for each category of tools: + +| Scope | Tools Enabled | +|-------|---------------| +| `repo` | Repository operations, issues, PRs, commits, branches, code search, workflows | +| `public_repo` | Star/unstar public repositories (implicit with `repo`) | +| `read:org` | Read organization info, list teams, team members | +| `write:org` | Organization management (includes `read:org`) | +| `admin:org` | Full organization administration (includes `write:org`, `read:org`) | +| `gist` | Create, update, and manage gists | +| `notifications` | List, manage, and dismiss notifications | +| `read:project` | Read GitHub Projects | +| `project` | Create and manage GitHub Projects (includes `read:project`) | +| `security_events` | Code scanning, Dependabot, secret scanning alerts (implicit with `repo`) | +| `user` | Update user profile | +| `read:user` | Read user profile information | + +### Scope Hierarchy + +Some scopes implicitly include others: + +- `repo` → includes `public_repo`, `security_events` +- `admin:org` → includes `write:org` → includes `read:org` +- `project` → includes `read:project` + +This means if your token has `repo`, tools requiring `security_events` will also be available. + +## Recommended Token Scopes + +For full functionality, we recommend these scopes: + +| Use Case | Recommended Scopes | +|----------|-------------------| +| Basic development | `repo`, `read:org` | +| Full development | `repo`, `admin:org`, `gist`, `notifications`, `project` | +| Read-only access | `repo` (with `--read-only` flag) | +| Security analysis | `repo` (includes `security_events`) | + +## Graceful Degradation + +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. + +``` +WARN: failed to fetch token scopes, continuing without scope filtering +``` + +## Fine-Grained Personal Access Tokens + +Fine-grained PATs use a different permission model and don't return OAuth scopes in the `X-OAuth-Scopes` header. When using fine-grained PATs, scope filtering will be skipped and all tools will be available. The GitHub API will still enforce permissions at the API level. + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| Missing expected tools | Token lacks required scope | Add the scope to your PAT | +| 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 | + +## Related Documentation + +- [Server Configuration Guide](./server-configuration.md) +- [GitHub PAT Documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [OAuth Scopes Reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index e8b7637bd..3338a9275 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -12,6 +12,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | +| Scope Filtering | Always enabled | Always enabled | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -330,6 +331,16 @@ Lockdown mode ensures the server only surfaces content in public repositories fr --- +### Scope Filtering + +**Automatic feature:** The server automatically detects your PAT's OAuth scopes and only shows tools you have permission to use. + +This happens transparently at startup - no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. + +See [OAuth Scope Filtering](./scope-filtering.md) for details on which scopes enable which tools. + +--- + ## Troubleshooting | Problem | Cause | Solution | diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 7da2f825f..73aaa8518 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -324,11 +324,6 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration - - // EnableScopeFiltering enables PAT scope-based tool filtering. - // When true, the server will fetch the token's OAuth scopes at startup - // and hide tools that require scopes the token doesn't have. - EnableScopeFiltering bool } // RunStdioServer is not concurrent safe. @@ -353,18 +348,16 @@ func RunStdioServer(cfg StdioServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode, "scopeFiltering", cfg.EnableScopeFiltering) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // Fetch token scopes if scope filtering is enabled + // Fetch token scopes for scope-based tool filtering var tokenScopes []string - if cfg.EnableScopeFiltering { - fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) - if err != nil { - logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) - } else { - tokenScopes = fetchedScopes - logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) - } + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } ghServer, err := NewMCPServer(MCPServerConfig{ From f1b9f906e3f70462e34f19b07b0ab694fd57ab36 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:14:39 +0100 Subject: [PATCH 04/13] Only check scopes for classic PATs (ghp_ prefix) - Scope filtering only applies to classic PATs which return X-OAuth-Scopes - Fine-grained PATs and other token types skip filtering (all tools shown) - Updated docs to clarify PAT filtering vs OAuth scope challenges --- docs/scope-filtering.md | 24 +++++++++++++++++++----- internal/ghmcp/server.go | 18 ++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 888a0e1f8..8dabd0d79 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -1,13 +1,25 @@ -# OAuth Scope Filtering +# PAT Scope Filtering -The GitHub MCP Server automatically filters available tools based on your 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. +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. + +> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs and other token types don't support scope detection. ## How It Works -When the server starts, 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. +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. **Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes. +## PAT vs OAuth Authentication + +| Authentication | Scope Handling | +|---------------|----------------| +| **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden | +| **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it | +| **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions | + +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. + ## Checking Your Token's Scopes To see what scopes your token has, you can run: @@ -70,9 +82,11 @@ 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 ``` -## Fine-Grained Personal Access Tokens +## 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. -Fine-grained PATs use a different permission model and don't return OAuth scopes in the `X-OAuth-Scopes` header. When using fine-grained PATs, scope filtering will be skipped and all tools will be available. The GitHub API will still enforce permissions at the API level. +**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. ## Troubleshooting diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 73aaa8518..165886606 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -350,14 +350,20 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // Fetch token scopes for scope-based tool filtering + // Fetch token scopes for scope-based tool filtering (PAT tokens only) + // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. + // Fine-grained PATs and other token types don't support this, so we skip filtering. var tokenScopes []string - fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) - if err != nil { - logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + if strings.HasPrefix(cfg.Token, "ghp_") { + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + } } else { - tokenScopes = fetchedScopes - logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + logger.Debug("skipping scope filtering for non-PAT token") } ghServer, err := NewMCPServer(MCPServerConfig{ From 4b13f176b0e18a008eb3919f1b9c913601668b79 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:19:12 +0100 Subject: [PATCH 05/13] Remove manual scope-to-tools table from docs The README already has auto-generated tool documentation with scopes. Keep only the scope hierarchy explanation which is structural. --- docs/scope-filtering.md | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 8dabd0d79..6e251fdb3 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -34,26 +34,7 @@ Example output: x-oauth-scopes: delete_repo, gist, read:org, repo ``` -## Scopes and Tools - -The following table shows which OAuth scopes are required for each category of tools: - -| Scope | Tools Enabled | -|-------|---------------| -| `repo` | Repository operations, issues, PRs, commits, branches, code search, workflows | -| `public_repo` | Star/unstar public repositories (implicit with `repo`) | -| `read:org` | Read organization info, list teams, team members | -| `write:org` | Organization management (includes `read:org`) | -| `admin:org` | Full organization administration (includes `write:org`, `read:org`) | -| `gist` | Create, update, and manage gists | -| `notifications` | List, manage, and dismiss notifications | -| `read:project` | Read GitHub Projects | -| `project` | Create and manage GitHub Projects (includes `read:project`) | -| `security_events` | Code scanning, Dependabot, secret scanning alerts (implicit with `repo`) | -| `user` | Update user profile | -| `read:user` | Read user profile information | - -### Scope Hierarchy +## Scope Hierarchy Some scopes implicitly include others: @@ -63,16 +44,7 @@ Some scopes implicitly include others: This means if your token has `repo`, tools requiring `security_events` will also be available. -## Recommended Token Scopes - -For full functionality, we recommend these scopes: - -| Use Case | Recommended Scopes | -|----------|-------------------| -| Basic development | `repo`, `read:org` | -| Full development | `repo`, `admin:org`, `gist`, `notifications`, `project` | -| Read-only access | `repo` (with `--read-only` flag) | -| Security analysis | `repo` (includes `security_events`) | +Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. ## Graceful Degradation From dbd1a209dbc3dc7dfc549ea8878c3be59bf61897 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:25:47 +0100 Subject: [PATCH 06/13] Update pkg/scopes/filter.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/scopes/filter.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/scopes/filter.go b/pkg/scopes/filter.go index 143b736e2..3eb3457a5 100644 --- a/pkg/scopes/filter.go +++ b/pkg/scopes/filter.go @@ -4,6 +4,4 @@ // For PATs, we cannot issue OAuth scope challenges, so we hide tools that // require scopes the token doesn't have. // -// The CreateToolScopeFilter function should be called from the github package -// or other packages that can import inventory to create the actual filter. package scopes From f8b698c743041207249bbe70e9aa97409c2ce2f3 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:46:46 +0100 Subject: [PATCH 07/13] Document that GitHub App and server-to-server tokens are not filtered --- docs/scope-filtering.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 6e251fdb3..5e3e353d5 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -2,7 +2,7 @@ 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. -> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs and other token types don't support scope detection. +> **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. ## How It Works @@ -17,6 +17,8 @@ When the server starts with a classic PAT, it makes a lightweight HTTP HEAD requ | **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden | | **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it | | **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions | +| **GitHub App** (`ghs_`) | No filtering—all tools shown, permissions based on app installation | +| **Server-to-server** | No filtering—all tools shown, permissions based on app/token configuration | 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. @@ -60,6 +62,10 @@ WARN: failed to fetch token scopes, continuing without scope filtering **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. +## GitHub App and Server-to-Server Tokens + +**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. + ## Troubleshooting | Problem | Cause | Solution | From 49fd7c68f9222bd29cc8abe01a9f91a704a51785 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:47:50 +0100 Subject: [PATCH 08/13] Add tip about editing PAT scopes in GitHub UI --- docs/scope-filtering.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 5e3e353d5..979a4d59e 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -70,10 +70,12 @@ WARN: failed to fetch token scopes, continuing without scope filtering | Problem | Cause | Solution | |---------|-------|----------| -| Missing expected tools | Token lacks required scope | Add the scope to your PAT | +| 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 | +> **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. + ## Related Documentation - [Server Configuration Guide](./server-configuration.md) From d65bd5b4bcc83a87b098f1e2e0ec68c6950be489 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:54:12 +0100 Subject: [PATCH 09/13] Remove empty filter.go and document OAuth scope challenges --- docs/scope-filtering.md | 12 ++++++++++++ pkg/scopes/filter.go | 7 ------- 2 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 pkg/scopes/filter.go diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 979a4d59e..c40bf729f 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -22,6 +22,18 @@ When the server starts with a classic PAT, it makes a lightweight HTTP HEAD requ 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. +## OAuth Scope Challenges (Remote Server) + +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. + +**How it works:** +1. You attempt to use a tool (e.g., creating an issue) +2. If your current OAuth token lacks the required scope, the server returns an OAuth scope challenge +3. Your MCP client prompts you to authorize the additional scope +4. After authorization, the operation completes successfully + +This provides a smoother user experience for OAuth users since you only grant permissions as needed, rather than requesting all scopes upfront. + ## Checking Your Token's Scopes To see what scopes your token has, you can run: diff --git a/pkg/scopes/filter.go b/pkg/scopes/filter.go deleted file mode 100644 index 3eb3457a5..000000000 --- a/pkg/scopes/filter.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package scopes provides OAuth scope checking utilities for GitHub MCP Server. -// -// This file contains utilities for filtering tools based on token scopes. -// For PATs, we cannot issue OAuth scope challenges, so we hide tools that -// require scopes the token doesn't have. -// -package scopes From e71bd3156a456485473e19d52e6993d7b6956364 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:56:10 +0100 Subject: [PATCH 10/13] Fix server-configuration.md scope filtering description --- docs/server-configuration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 3338a9275..b9f8b5fa7 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -333,11 +333,11 @@ Lockdown mode ensures the server only surfaces content in public repositories fr ### Scope Filtering -**Automatic feature:** The server automatically detects your PAT's OAuth scopes and only shows tools you have permission to use. +**Automatic feature:** The server automatically detects your classic PAT's OAuth scopes and only shows tools you have permission to use. -This happens transparently at startup - no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. +This happens transparently at startup for classic PATs (`ghp_` prefix)—no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. -See [OAuth Scope Filtering](./scope-filtering.md) for details on which scopes enable which tools. +Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. --- From 78f2549f574604fce281f9a0c0a466dc53747a7f Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:58:50 +0100 Subject: [PATCH 11/13] Mention OAuth scope challenges in server-configuration.md --- docs/server-configuration.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index b9f8b5fa7..46ec3bc64 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -333,11 +333,15 @@ Lockdown mode ensures the server only surfaces content in public repositories fr ### Scope Filtering -**Automatic feature:** The server automatically detects your classic PAT's OAuth scopes and only shows tools you have permission to use. +**Automatic feature:** The server handles OAuth scopes differently depending on authentication type: -This happens transparently at startup for classic PATs (`ghp_` prefix)—no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. +- **Classic PATs** (`ghp_` prefix): Tools are filtered at startup based on token scopes—you only see tools you have permission to use +- **OAuth** (remote server): Uses scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it +- **Other tokens**: No filtering—all tools shown, API enforces permissions -Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. +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. + +See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. --- From 3397e961ac5457ae920831d5022887d944d4984d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 16:52:00 +0100 Subject: [PATCH 12/13] Don't filter read-only repo tools (work on public repos without scope) --- pkg/github/scope_filter.go | 28 ++++++++++++++++++++++++++++ pkg/github/scope_filter_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go index b1aa77c85..42f8e98b0 100644 --- a/pkg/github/scope_filter.go +++ b/pkg/github/scope_filter.go @@ -7,6 +7,29 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" ) +// repoScopesSet contains scopes that grant access to repository content. +// Tools requiring only these scopes work on public repos without any token scope, +// so we don't filter them out even if the token lacks repo/public_repo. +var repoScopesSet = map[string]bool{ + string(scopes.Repo): true, + string(scopes.PublicRepo): true, +} + +// onlyRequiresRepoScopes returns true if all of the tool's accepted 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 { + return false + } + for _, scope := range acceptedScopes { + if !repoScopesSet[scope] { + return false + } + } + return true +} + // CreateToolScopeFilter creates an inventory.ToolFilter that filters tools // based on the token's OAuth scopes. // @@ -19,6 +42,7 @@ import ( // // The filter returns true (include tool) if: // - The tool has no scope requirements (AcceptedScopes 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 // // Example usage: @@ -31,6 +55,10 @@ import ( // 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) { + return true, nil + } return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil } } diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go index 48eb52aa0..451d1a64e 100644 --- a/pkg/github/scope_filter_test.go +++ b/pkg/github/scope_filter_test.go @@ -27,11 +27,27 @@ func TestCreateToolScopeFilter(t *testing.T) { AcceptedScopes: []string{"repo"}, } + toolRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"repo"}, + } + toolPublicRepoScope := &inventory.ServerTool{ Tool: mcp.Tool{Name: "public_repo_tool"}, AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted } + toolPublicRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "public_repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"public_repo", "repo"}, + } + toolGistScope := &inventory.ServerTool{ Tool: mcp.Tool{Name: "gist_tool"}, AcceptedScopes: []string{"gist"}, @@ -96,6 +112,18 @@ func TestCreateToolScopeFilter(t *testing.T) { tool: toolRepoScope, expected: false, }, + { + name: "empty token scopes CAN see read-only repo tools (public repos)", + tokenScopes: []string{}, + tool: toolRepoScopeReadOnly, + expected: true, + }, + { + name: "empty token scopes CAN see read-only public_repo tools", + tokenScopes: []string{}, + tool: toolPublicRepoScopeReadOnly, + expected: true, + }, { name: "token with multiple scopes where one matches", tokenScopes: []string{"gist", "repo"}, From 9bf0cf5ef7aadc37bc80f9f94314db4e18f79925 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 16:55:27 +0100 Subject: [PATCH 13/13] Document public repo access quirk for read-only tools --- docs/scope-filtering.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index c40bf729f..f29d631ca 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -60,6 +60,14 @@ This means if your token has `repo`, tools requiring `security_events` will also Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. +## Public Repository Access + +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. + +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. + +> **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. + ## Graceful Degradation 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.