diff --git a/README.md b/README.md index 1a0f6b1c4..79c545d32 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,12 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: chmod 600 ~/.your-app/config.json ``` +> **💡 Tip**: Use the `compare-scopes` command to check if your PAT has all required scopes: +> ```bash +> GITHUB_PERSONAL_ACCESS_TOKEN=your_token script/compare-scopes +> ``` +> See [Scope Filtering documentation](docs/scope-filtering.md#compare-token-scopes-with-required-scopes) for details. + ### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com) diff --git a/cmd/github-mcp-server/compare_scopes_cmd.go b/cmd/github-mcp-server/compare_scopes_cmd.go new file mode 100644 index 000000000..36017080a --- /dev/null +++ b/cmd/github-mcp-server/compare_scopes_cmd.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var compareScopesCmd = &cobra.Command{ + Use: "compare-scopes", + Short: "Compare PAT scopes with required scopes for MCP tools", + Long: `Compare the scopes granted to your Personal Access Token (PAT) with the scopes +required by the GitHub MCP server tools. This helps identify missing permissions +that would prevent certain tools from working. + +The PAT is provided via the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. +Use --gh-host to specify a GitHub Enterprise Server host.`, + RunE: func(_ *cobra.Command, _ []string) error { + return compareScopes() + }, +} + +func init() { + rootCmd.AddCommand(compareScopesCmd) +} + +func compareScopes() error { + // Get the token from environment + token := viper.GetString("personal_access_token") + if token == "" { + return fmt.Errorf("GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set") + } + + // Get the API host + apiHost := viper.GetString("host") + if apiHost == "" { + apiHost = "https://api.github.com" + } else if !strings.HasPrefix(apiHost, "http://") && !strings.HasPrefix(apiHost, "https://") { + apiHost = "https://" + apiHost + } + + // Fetch the PAT's scopes + ctx := context.Background() + fetcher := scopes.NewFetcher(scopes.FetcherOptions{ + APIHost: apiHost, + }) + + fmt.Fprintf(os.Stderr, "Fetching token scopes from %s...\n", apiHost) + tokenScopes, err := fetcher.FetchTokenScopes(ctx, token) + if err != nil { + return fmt.Errorf("failed to fetch token scopes: %w", err) + } + + // Get all required scopes from the inventory + t, _ := translations.TranslationHelper() + inventory := github.NewInventory(t).WithToolsets([]string{"all"}).Build() + + allTools := inventory.AllTools() + + // Collect unique required and accepted scopes + requiredScopesSet := make(map[string]bool) + acceptedScopesSet := make(map[string]bool) + + for _, tool := range allTools { + for _, scope := range tool.RequiredScopes { + requiredScopesSet[scope] = true + } + for _, scope := range tool.AcceptedScopes { + acceptedScopesSet[scope] = true + } + } + + // Convert to sorted slices + var requiredScopes []string + for scope := range requiredScopesSet { + requiredScopes = append(requiredScopes, scope) + } + sort.Strings(requiredScopes) + + var acceptedScopes []string + for scope := range acceptedScopesSet { + acceptedScopes = append(acceptedScopes, scope) + } + sort.Strings(acceptedScopes) + + // Sort token scopes + sort.Strings(tokenScopes) + + // Print results + fmt.Println("\n=== PAT Scope Comparison ===") + fmt.Println() + + // Show token scopes + fmt.Println("Token Scopes:") + if len(tokenScopes) == 0 { + fmt.Println(" (none - this may be a fine-grained PAT which doesn't expose scopes)") + } else { + for _, scope := range tokenScopes { + fmt.Printf(" - %s\n", scope) + } + } + + // Show required scopes + fmt.Println("\nRequired Scopes (by tools):") + if len(requiredScopes) == 0 { + fmt.Println(" (none)") + } else { + for _, scope := range requiredScopes { + fmt.Printf(" - %s\n", scope) + } + } + + // Calculate missing scopes - check each tool to see if token has required permissions + tokenScopesSet := make(map[string]bool) + for _, scope := range tokenScopes { + tokenScopesSet[scope] = true + } + + // Track which tools are missing scopes and collect unique missing scopes + missingScopesSet := make(map[string]bool) + toolsMissingScopes := make(map[string][]string) // scope -> list of affected tools + + for _, tool := range allTools { + // Skip tools that don't require any scopes + if len(tool.AcceptedScopes) == 0 { + continue + } + + // Use the existing HasRequiredScopes function which handles hierarchy correctly + if !scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes) { + // This tool is not usable - track which required scopes are missing + for _, reqScope := range tool.RequiredScopes { + missingScopesSet[reqScope] = true + toolsMissingScopes[reqScope] = append(toolsMissingScopes[reqScope], tool.Tool.Name) + } + } + } + + // Convert to sorted slice + var missingScopes []string + for scope := range missingScopesSet { + missingScopes = append(missingScopes, scope) + } + sort.Strings(missingScopes) + + // Find extra scopes (scopes in token but not required) + var extraScopes []string + for _, scope := range tokenScopes { + if !requiredScopesSet[scope] && !acceptedScopesSet[scope] { + extraScopes = append(extraScopes, scope) + } + } + sort.Strings(extraScopes) + + // Print comparison summary + fmt.Println("\n=== Comparison Summary ===") + fmt.Println() + + if len(missingScopes) > 0 { + fmt.Println("Missing Scopes (required by tools but not granted to token):") + for _, scope := range missingScopes { + fmt.Printf(" - %s\n", scope) + // Show which tools require this scope + if tools, ok := toolsMissingScopes[scope]; ok && len(tools) > 0 { + // Limit to first 5 tools to avoid overwhelming output + displayTools := tools + if len(displayTools) > 5 { + displayTools = tools[:5] + fmt.Printf(" Tools affected: %s, ... and %d more\n", strings.Join(displayTools, ", "), len(tools)-5) + } else { + fmt.Printf(" Tools affected: %s\n", strings.Join(displayTools, ", ")) + } + } + } + fmt.Println("\nWarning: Some tools may not be available due to missing scopes.") + return fmt.Errorf("token is missing %d required scope(s)", len(missingScopes)) + } + + fmt.Println("✓ Token has all required scopes") + + if len(extraScopes) > 0 { + fmt.Println("\nExtra Scopes (granted to token but not required by any tool):") + for _, scope := range extraScopes { + fmt.Printf(" - %s\n", scope) + } + } + + return nil +} diff --git a/cmd/github-mcp-server/compare_scopes_cmd_test.go b/cmd/github-mcp-server/compare_scopes_cmd_test.go new file mode 100644 index 000000000..ed4a14d69 --- /dev/null +++ b/cmd/github-mcp-server/compare_scopes_cmd_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "testing" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCompareScopesLogic tests the core logic of scope comparison +func TestCompareScopesLogic(t *testing.T) { + // Get all tools from inventory + tr, _ := translations.TranslationHelper() + inventory := github.NewInventory(tr).WithToolsets([]string{"all"}).Build() + allTools := inventory.AllTools() + + // Collect unique required scopes + requiredScopesSet := make(map[string]bool) + for _, tool := range allTools { + for _, scope := range tool.RequiredScopes { + requiredScopesSet[scope] = true + } + } + + // Should have some required scopes + require.NotEmpty(t, requiredScopesSet, "Expected some tools to require scopes") + + // Test with token that has all scopes + allScopes := make([]string, 0, len(requiredScopesSet)) + for scope := range requiredScopesSet { + allScopes = append(allScopes, scope) + } + + // Check that each tool either requires no scopes or has its requirements met + missingCount := 0 + for _, tool := range allTools { + if len(tool.AcceptedScopes) == 0 { + continue + } + if !scopes.HasRequiredScopes(allScopes, tool.AcceptedScopes) { + missingCount++ + } + } + + // When we have all required scopes, no tools should be missing access + assert.Equal(t, 0, missingCount, "Expected no tools to be missing when all scopes present") + + // Test with empty token (no scopes) + emptyScopes := []string{} + missingWithEmpty := 0 + for _, tool := range allTools { + if len(tool.AcceptedScopes) == 0 { + continue + } + if !scopes.HasRequiredScopes(emptyScopes, tool.AcceptedScopes) { + missingWithEmpty++ + } + } + + // With empty scopes, some tools requiring scopes should be inaccessible + assert.Greater(t, missingWithEmpty, 0, "Expected some tools to be missing with empty scopes") +} + +// TestScopeHierarchyInComparison tests that scope hierarchy is respected +func TestScopeHierarchyInComparison(t *testing.T) { + // If token has "repo", it should grant access to tools requiring "public_repo" + tokenWithRepo := []string{"repo"} + acceptedScopes := []string{"public_repo", "repo"} // Tool accepts either + + hasAccess := scopes.HasRequiredScopes(tokenWithRepo, acceptedScopes) + assert.True(t, hasAccess, "Token with 'repo' should grant access to tools accepting 'public_repo'") + + // If token has "public_repo", it should NOT grant access to tools requiring full "repo" + tokenWithPublicRepo := []string{"public_repo"} + acceptedScopesFullRepo := []string{"repo"} + + hasAccess = scopes.HasRequiredScopes(tokenWithPublicRepo, acceptedScopesFullRepo) + assert.False(t, hasAccess, "Token with 'public_repo' should NOT grant access to tools requiring full 'repo'") +} + +// TestInventoryHasToolsWithScopes verifies the inventory contains tools with scope requirements +func TestInventoryHasToolsWithScopes(t *testing.T) { + tr, _ := translations.TranslationHelper() + inventory := github.NewInventory(tr).WithToolsets([]string{"all"}).Build() + allTools := inventory.AllTools() + + // Count tools with scope requirements + toolsWithScopes := 0 + toolsWithoutScopes := 0 + + for _, tool := range allTools { + if len(tool.RequiredScopes) > 0 { + toolsWithScopes++ + } else { + toolsWithoutScopes++ + } + } + + // We should have both tools with and without scope requirements + assert.Greater(t, toolsWithScopes, 0, "Expected some tools to require scopes") + assert.Greater(t, toolsWithoutScopes, 0, "Expected some tools to not require scopes") + + t.Logf("Tools with scopes: %d, Tools without scopes: %d", toolsWithScopes, toolsWithoutScopes) +} diff --git a/cmd/github-mcp-server/compare_scopes_integration_test.go b/cmd/github-mcp-server/compare_scopes_integration_test.go new file mode 100644 index 000000000..304900e48 --- /dev/null +++ b/cmd/github-mcp-server/compare_scopes_integration_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCompareScopesIntegration tests the full compare-scopes flow with a mock server +func TestCompareScopesIntegration(t *testing.T) { + // Create a mock GitHub API server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check authorization header + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Return some test scopes + w.Header().Set(scopes.OAuthScopesHeader, "repo, read:org, gist") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Configure viper with test values + viper.Set("personal_access_token", "test_token") + viper.Set("host", server.URL) + + // Capture output by temporarily redirecting stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + // Run the compareScopes function + err := compareScopes() + + // Restore stderr + w.Close() + os.Stderr = oldStderr + + // Read captured output + outputBytes, _ := io.ReadAll(r) + output := string(outputBytes) + + // Verify output contains expected sections + assert.Contains(t, output, "Fetching token scopes from") + + // The function may return an error if some scopes are missing + // We're mainly testing that it runs without panicking + if err != nil { + // It's ok if there are missing scopes - that's expected + assert.Contains(t, err.Error(), "missing") + } +} + +// TestCompareScopesWithoutToken tests that the command fails gracefully without a token +func TestCompareScopesWithoutToken(t *testing.T) { + // Clear the token + viper.Set("personal_access_token", "") + + // Run the compareScopes function + err := compareScopes() + + // Should return an error about missing token + require.Error(t, err) + assert.Contains(t, err.Error(), "GITHUB_PERSONAL_ACCESS_TOKEN") +} + +// TestCompareScopesWithInvalidToken tests error handling with invalid token +func TestCompareScopesWithInvalidToken(t *testing.T) { + // Create a mock server that returns 401 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + // Configure viper + viper.Set("personal_access_token", "invalid_token") + viper.Set("host", server.URL) + + // Suppress stderr output during this test + oldStderr := os.Stderr + os.Stderr = nil + defer func() { os.Stderr = oldStderr }() + + // Run the compareScopes function + err := compareScopes() + + // Should return an error about invalid token + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid or expired token") +} diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index f29d631ca..e1688f2ae 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -36,6 +36,8 @@ This provides a smoother user experience for OAuth users since you only grant pe ## Checking Your Token's Scopes +### Quick Check via API + To see what scopes your token has, you can run: ```bash @@ -48,6 +50,62 @@ Example output: x-oauth-scopes: delete_repo, gist, read:org, repo ``` +### Compare Token Scopes with Required Scopes + +The GitHub MCP Server includes a built-in tool to compare your PAT's scopes with the scopes required by all available tools: + +```bash +# Using the script +GITHUB_PERSONAL_ACCESS_TOKEN=your_token script/compare-scopes + +# Or directly with the command +GITHUB_PERSONAL_ACCESS_TOKEN=your_token go run ./cmd/github-mcp-server compare-scopes + +# For GitHub Enterprise Server +GITHUB_PERSONAL_ACCESS_TOKEN=your_token go run ./cmd/github-mcp-server compare-scopes --gh-host=github.example.com +``` + +The command will: +1. Fetch your token's scopes from the GitHub API +2. Collect all required scopes from the MCP tools +3. Display a comparison showing: + - Token scopes (what your PAT has) + - Required scopes (what tools need) + - Missing scopes (what's required but not granted) + - Extra scopes (what your PAT has but isn't used) +4. List which tools are affected by missing scopes +5. Exit with code 1 if any required scopes are missing (useful for CI/automation) + +Example output: +``` +=== PAT Scope Comparison === + +Token Scopes: + - gist + - read:org + - repo + +Required Scopes (by tools): + - gist + - read:org + - read:project + - repo + - user + +=== Comparison Summary === + +Missing Scopes (required by tools but not granted to token): + - read:project + Tools affected: list_projects, get_project, ... + - user + Tools affected: get_me + +Warning: Some tools may not be available due to missing scopes. +Error: token is missing 2 required scope(s) +``` + +> **Note:** For fine-grained PATs, the command will show "(none)" for token scopes since these tokens don't expose OAuth scopes via the API. All tools will still be shown, but the GitHub API enforces permissions at runtime. + ## Scope Hierarchy Some scopes implicitly include others: diff --git a/script/compare-scopes b/script/compare-scopes new file mode 100755 index 000000000..1fa130526 --- /dev/null +++ b/script/compare-scopes @@ -0,0 +1,21 @@ +#!/bin/bash + +# This script compares the scopes of your GitHub PAT with the scopes required by the MCP server tools. +# It helps identify missing permissions that would prevent certain tools from working. +# +# Usage: +# GITHUB_PERSONAL_ACCESS_TOKEN=your_token script/compare-scopes +# GITHUB_PERSONAL_ACCESS_TOKEN=your_token GITHUB_HOST=github.example.com script/compare-scopes + +set -e + +if [ -z "$GITHUB_PERSONAL_ACCESS_TOKEN" ]; then + echo "Error: GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set" >&2 + echo "" >&2 + echo "Usage:" >&2 + echo " GITHUB_PERSONAL_ACCESS_TOKEN=your_token script/compare-scopes" >&2 + exit 1 +fi + +# Run the compare-scopes command +exec go run ./cmd/github-mcp-server compare-scopes