How PAT Scope Filtering Limits MCP Server Tool Availability in GitHub MCP Server

PAT scope filtering automatically hides MCP tools that require OAuth scopes your classic personal access token lacks, preventing permission errors while keeping read-only public repository tools visible.

When you start the github/github-mcp-server with a classic Personal Access Token (PAT), the server inspects the token's OAuth scopes via the X‑OAuth‑Scopes response header from the GitHub API. This PAT scope filtering mechanism then dynamically limits tool availability by removing any tools that require scopes the token does not possess, creating a cleaner interface and reducing "permission denied" errors.

How PAT Scope Filtering Works

The filtering process follows a deterministic five-step pipeline that evaluates every incoming request. Only classic PATs (those prefixed with ghp_) trigger this workflow; fine-grained PATs, OAuth tokens, and GitHub App tokens bypass filtering entirely.

Token Detection and Classification

The system first identifies whether the authentication header contains a classic PAT. In pkg/utils/token.go, the ParseAuthorizationHeader function parses the Authorization header and classifies the token type.

// pkg/utils/token.go#L44-L66
// TokenTypePersonalAccessToken identifies classic PATs (ghp_ prefix)
tokenType, token := ParseAuthorizationHeader(authHeader)
if tokenType == TokenTypePersonalAccessToken {
    // Trigger scope filtering workflow
}

Fetching OAuth Scopes from GitHub API

Once a classic PAT is detected, the server retrieves the token's granted scopes. The FetchTokenScopes method in pkg/scopes/fetcher.go sends a lightweight HTTP HEAD request to https://api.github.com/ and extracts the scopes from the X‑OAuth‑Scopes response header.

// pkg/scopes/fetcher.go#L65-L71
func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) {
    req, _ := http.NewRequestWithContext(ctx, "HEAD", f.baseURL, nil)
    req.Header.Set("Authorization", "Bearer "+token)
    resp, err := f.client.Do(req)
    // Extract scopes from X-OAuth-Scopes header
    return parseScopes(resp.Header.Get("X-OAuth-Scopes")), err
}

Storing Scopes in Request Context

The WithPATScopes middleware in pkg/http/middleware/pat_scope.go runs on every request. It calls the fetcher when a classic PAT is present and stores the resulting scopes in the request context for downstream use.

// pkg/http/middleware/pat_scope.go#L12-L48
func WithPATScopes(logger *zap.Logger, fetcher *scopes.Fetcher) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := extractToken(r)
            if isClassicPAT(token) {
                scopes, err := fetcher.FetchTokenScopes(r.Context(), token)
                if err != nil {
                    logger.Warn("failed to fetch scopes, proceeding without filtering", zap.Error(err))
                    // Graceful degradation: don't filter if fetch fails
                } else {
                    r = r.WithContext(context.WithValue(r.Context(), scopesKey, scopes))
                }
            }
            next.ServeHTTP(w, r)
        })
    }
}

Tool Visibility Decisions

The actual filtering logic resides in pkg/github/scope_filter.go. The CreateToolScopeFilter function returns an inventory.ToolFilter that determines which tools appear in the MCP inventory.

// pkg/github/scope_filter.go#L56-L63
func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter {
    return func(tool inventory.ServerTool) bool {
        // Always show read-only tools that work on public repos
        if isPublicRepoReadOnly(tool) {
            return true
        }
        // Check if token has any of the tool's accepted scopes
        return scopes.HasRequiredScopes(tokenScopes, tool.RequiredScopes, tool.AcceptedScopes)
    }
}

Scope Hierarchy and Matching Logic

The HasRequiredScopes function in pkg/scopes/scopes.go handles the complex logic of scope hierarchies. It expands the token's scopes to include child scopes (for example, repo implies public_repo and security_events) and returns true if the token grants at least one of the tool's accepted scopes.

// pkg/scopes/scopes.go#L71-L95
func HasRequiredScopes(tokenScopes []string, required []string, accepted []string) bool {
    // Expand token scopes to include implied child scopes
    expanded := expandScopes(tokenScopes)
    
    // Check against accepted scopes (tool can work with any of these)
    for _, scope := range accepted {
        if contains(expanded, scope) {
            return true
        }
    }
    return false
}

Why PAT Scope Filtering Limits Tool Availability

PAT scope filtering directly constrains which operations users can perform through the MCP server by controlling tool visibility at the inventory level.

Visibility is driven by token scopes. When a tool declares RequiredScopes: ["admin:org"] in pkg/inventory/server_tool.go, the CreateToolScopeFilter hides that tool unless the PAT includes the admin:org scope. This prevents users from attempting operations that would inevitably fail with 403 Forbidden errors.

Read-only public repository tools remain accessible. The filter explicitly preserves tools that only require repo or public_repo scopes, even if the token has no scopes at all. These tools work on public repositories without authentication, ensuring basic functionality remains available.

Graceful degradation protects availability. If the scope fetch fails due to network errors or rate limiting, the middleware logs a warning and disables filtering entirely. This ensures that temporary API issues do not render the server unusable, though it may expose tools that will later fail during execution.

Implementing PAT Scope Filtering in Your MCP Server

To leverage PAT scope filtering in your own MCP server implementation, you must wire together the token detection middleware and the tool filter during server initialization.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    
    "github.com/github/github-mcp-server/pkg/github"
    "github.com/github/github-mcp-server/pkg/http/middleware"
    "github.com/github/github-mcp-server/pkg/scopes"
    "go.uber.org/zap"
)

func main() {
    logger, _ := zap.NewProduction()
    ctx := context.Background()
    
    // Retrieve classic PAT from environment
    token := os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN")
    if token == "" {
        log.Fatal("GITHUB_PERSONAL_ACCESS_TOKEN not set")
    }
    
    // Initialize the scope fetcher
    fetcher := scopes.NewFetcher("https://api.github.com")
    
    // Fetch scopes once at startup (optional optimization)
    tokenScopes, err := fetcher.FetchTokenScopes(ctx, token)
    if err != nil {
        logger.Warn("failed to fetch PAT scopes", zap.Error(err))
        // Proceed without filtering - all tools visible
        tokenScopes = []string{}
    }
    
    // Create the tool filter based on fetched scopes
    toolFilter := github.CreateToolScopeFilter(tokenScopes)
    
    // Build inventory with the scope-aware filter
    inventory := github.NewInventory().
        WithFilter(toolFilter).
        Build()
    
    // Apply middleware to refresh scopes per request (recommended)
    handler := middleware.WithPATScopes(logger, fetcher).Then(
        http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Retrieve scopes from context if needed
            scopes := r.Context().Value("patScopes")
            // Handle request using filtered inventory...
        }),
    )
    
    log.Println("MCP server started with PAT scope filtering enabled")
    http.ListenAndServe(":8080", handler)
}

This implementation demonstrates the complete flow: detecting the classic PAT, fetching scopes via FetchTokenScopes, creating a filter with CreateToolScopeFilter, and applying the WithPATScopes middleware to ensure every request has access to the current token permissions.

Summary

  • PAT scope filtering automatically hides MCP tools that require OAuth scopes your classic personal access token does not possess, reducing permission errors and cleaning up the tool inventory.
  • The system detects classic PATs (those prefixed with ghp_) in pkg/utils/token.go, then fetches granted scopes from the GitHub API's X‑OAuth‑Scopes header using pkg/scopes/fetcher.go.
  • The WithPATScopes middleware in pkg/http/middleware/pat_scope.go stores these scopes in the request context, enabling the CreateToolScopeFilter in pkg/github/scope_filter.go to make visibility decisions.
  • Tools declaring RequiredScopes in pkg/inventory/server_tool.go remain hidden unless the token possesses at least one accepted scope, with hierarchy expansion handled by HasRequiredScopes in pkg/scopes/scopes.go.
  • Read-only tools requiring only repo or public_repo scopes remain visible even for tokens with no scopes, and the system gracefully degrades to showing all tools if scope fetching fails.

Frequently Asked Questions

What happens if I use a fine-grained personal access token instead of a classic PAT?

Fine-grained personal access tokens bypass PAT scope filtering entirely. The server only performs scope detection and filtering for classic PATs (those with the ghp_ prefix). When using fine-grained tokens, OAuth tokens, or GitHub App tokens, all tools remain visible in the inventory, though the GitHub API will still enforce permissions and return 403 errors if the token lacks required repository or organization access.

How does the server handle network failures when fetching PAT scopes?

The WithPATScopes middleware in pkg/http/middleware/pat_scope.go implements graceful degradation for network failures. If the FetchTokenScopes call fails due to network errors, DNS issues, or GitHub API rate limiting, the middleware logs a warning and proceeds without adding scopes to the request context. This disables filtering for that request, ensuring all tools remain visible and the server remains functional, though users may encounter permission errors when attempting to use tools requiring scopes they don't possess.

Why do some tools remain visible even when my PAT has no scopes?

The CreateToolScopeFilter function in pkg/github/scope_filter.go explicitly preserves tools that only require repo or public_repo scopes, even for tokens with no granted scopes. These read-only tools operate on public repositories without authentication, allowing users to browse public code, read issues, and access public repository metadata regardless of their token's scope configuration. This ensures basic functionality remains available while still hiding administrative or write-operation tools that require specific OAuth scopes like admin:org or delete_repo.

Can I manually override PAT scope filtering to show all tools?

There is no configuration option to disable PAT scope filtering specifically for classic PATs within the server configuration. However, you can effectively bypass filtering by using authentication methods other than classic PATs, such as fine-grained personal access tokens, OAuth tokens, or GitHub App tokens, which skip the scope filtering step entirely. Alternatively, if you need to use a classic PAT but want all tools visible, you could modify the source code to remove the WithPATScopes middleware or return an empty filter from CreateToolScopeFilter, though this would result in permission errors when attempting to use tools requiring scopes your token lacks.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →