Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)
Expand Down
197 changes: 197 additions & 0 deletions cmd/github-mcp-server/compare_scopes_cmd.go
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions cmd/github-mcp-server/compare_scopes_cmd_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading