Skip to content

Commit 80c9694

Browse files
CopilotJoannaaKL
andcommitted
Add compare-scopes command with tests and documentation
- Created compare_scopes.go with complete implementation - Fetches token scopes from GitHub API using pkg/scopes/fetcher - Compares with required scopes from server inventory - Handles scope hierarchy (parent scopes cover child scopes) - Reports missing and extra scopes intelligently - Supports text and JSON output formats - Works with GitHub Enterprise via --gh-host flag - Added comprehensive unit tests in compare_scopes_test.go - Tests scope comparison logic - Tests scope hierarchy handling - Tests edge cases (empty scopes, etc.) - All tests passing - Created script/compare-scopes wrapper script for easy CLI usage - Added detailed documentation in docs/compare-scopes.md - Usage examples with various configurations - Output format examples - Scope hierarchy explanation - GitHub Enterprise support - Common workflows and troubleshooting scenarios Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com>
1 parent 309753a commit 80c9694

3 files changed

Lines changed: 688 additions & 0 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"sort"
9+
"strings"
10+
11+
"github.com/github/github-mcp-server/pkg/github"
12+
"github.com/github/github-mcp-server/pkg/scopes"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/spf13/cobra"
15+
"github.com/spf13/viper"
16+
)
17+
18+
// ScopeComparison represents the result of comparing token scopes with required scopes.
19+
type ScopeComparison struct {
20+
TokenScopes []string `json:"token_scopes"`
21+
RequiredScopes []string `json:"required_scopes"`
22+
MissingScopes []string `json:"missing_scopes"`
23+
ExtraScopes []string `json:"extra_scopes"`
24+
HasAllRequired bool `json:"has_all_required"`
25+
}
26+
27+
// CompareOutput is the full output structure for the compare-scopes command.
28+
type CompareOutput struct {
29+
Comparison ScopeComparison `json:"comparison"`
30+
EnabledToolsets []string `json:"enabled_toolsets"`
31+
ReadOnly bool `json:"read_only"`
32+
Tools []ToolScopeInfo `json:"tools,omitempty"`
33+
ScopesByTool map[string][]string `json:"scopes_by_tool,omitempty"`
34+
}
35+
36+
var compareScopesCmd = &cobra.Command{
37+
Use: "compare-scopes",
38+
Short: "Compare PAT token scopes with required scopes",
39+
Long: `Compare the OAuth scopes granted to a PAT token with the scopes required by enabled tools.
40+
41+
This command fetches the scopes from your GitHub Personal Access Token and compares
42+
them with the scopes required by the enabled tools. It reports any missing or extra
43+
scopes to help you understand if your token has the necessary permissions.
44+
45+
The token is read from the GITHUB_PERSONAL_ACCESS_TOKEN environment variable or
46+
can be provided via the --token flag.
47+
48+
The output format can be controlled with the --output flag:
49+
- text (default): Human-readable text output with colored indicators
50+
- json: JSON output for programmatic use
51+
52+
Examples:
53+
# Compare using token from environment variable
54+
export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_...
55+
github-mcp-server compare-scopes
56+
57+
# Compare for specific toolsets
58+
github-mcp-server compare-scopes --toolsets=repos,issues,pull_requests
59+
60+
# Compare with token from flag
61+
github-mcp-server compare-scopes --token=ghp_...
62+
63+
# Output as JSON
64+
github-mcp-server compare-scopes --output=json`,
65+
RunE: func(_ *cobra.Command, _ []string) error {
66+
return runCompareScopes()
67+
},
68+
}
69+
70+
func init() {
71+
compareScopesCmd.Flags().StringP("output", "o", "text", "Output format: text or json")
72+
compareScopesCmd.Flags().String("token", "", "GitHub Personal Access Token (overrides GITHUB_PERSONAL_ACCESS_TOKEN env var)")
73+
_ = viper.BindPFlag("compare-scopes-output", compareScopesCmd.Flags().Lookup("output"))
74+
_ = viper.BindPFlag("compare-scopes-token", compareScopesCmd.Flags().Lookup("token"))
75+
76+
rootCmd.AddCommand(compareScopesCmd)
77+
}
78+
79+
func runCompareScopes() error {
80+
// Get token from flag or environment variable
81+
token := viper.GetString("compare-scopes-token")
82+
if token == "" {
83+
token = viper.GetString("personal_access_token")
84+
}
85+
if token == "" {
86+
return fmt.Errorf("GitHub Personal Access Token not provided. Set GITHUB_PERSONAL_ACCESS_TOKEN or use --token flag")
87+
}
88+
89+
// Get toolsets configuration (same logic as list-scopes)
90+
var enabledToolsets []string
91+
if viper.IsSet("toolsets") {
92+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
93+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
94+
}
95+
}
96+
97+
// Get specific tools
98+
var enabledTools []string
99+
if viper.IsSet("tools") {
100+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
101+
return fmt.Errorf("failed to unmarshal tools: %w", err)
102+
}
103+
}
104+
105+
readOnly := viper.GetBool("read-only")
106+
outputFormat := viper.GetString("compare-scopes-output")
107+
108+
// Get API host for GitHub Enterprise support
109+
apiHost := viper.GetString("host")
110+
if apiHost != "" {
111+
// Ensure it starts with https://
112+
if !strings.HasPrefix(apiHost, "http://") && !strings.HasPrefix(apiHost, "https://") {
113+
apiHost = "https://" + apiHost
114+
}
115+
// GitHub Enterprise uses /api/v3 endpoint
116+
if !strings.Contains(apiHost, "api.github.com") {
117+
apiHost = strings.TrimSuffix(apiHost, "/") + "/api/v3"
118+
}
119+
}
120+
121+
// Fetch token scopes from GitHub API
122+
ctx := context.Background()
123+
var tokenScopes []string
124+
var err error
125+
126+
if apiHost == "" {
127+
tokenScopes, err = scopes.FetchTokenScopes(ctx, token)
128+
} else {
129+
tokenScopes, err = scopes.FetchTokenScopesWithHost(ctx, token, apiHost)
130+
}
131+
if err != nil {
132+
return fmt.Errorf("failed to fetch token scopes: %w", err)
133+
}
134+
135+
// Build inventory to get required scopes
136+
t, _ := translations.TranslationHelper()
137+
inventoryBuilder := github.NewInventory(t).
138+
WithReadOnly(readOnly)
139+
140+
if enabledToolsets != nil {
141+
inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets)
142+
}
143+
if len(enabledTools) > 0 {
144+
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
145+
}
146+
147+
inv := inventoryBuilder.Build()
148+
149+
// Collect tool scopes
150+
scopesOutput := collectToolScopes(inv, readOnly)
151+
152+
// Compare scopes
153+
comparison := compareScopes(tokenScopes, scopesOutput.UniqueScopes)
154+
155+
// Create output structure
156+
output := CompareOutput{
157+
Comparison: comparison,
158+
EnabledToolsets: scopesOutput.EnabledToolsets,
159+
ReadOnly: readOnly,
160+
Tools: scopesOutput.Tools,
161+
ScopesByTool: scopesOutput.ScopesByTool,
162+
}
163+
164+
// Output based on format
165+
switch outputFormat {
166+
case "json":
167+
return outputCompareJSON(output)
168+
default:
169+
return outputCompareText(output)
170+
}
171+
}
172+
173+
func compareScopes(tokenScopes, requiredScopes []string) ScopeComparison {
174+
// Create sets for efficient lookup
175+
tokenSet := make(map[string]bool)
176+
for _, scope := range tokenScopes {
177+
tokenSet[scope] = true
178+
}
179+
180+
requiredSet := make(map[string]bool)
181+
for _, scope := range requiredScopes {
182+
requiredSet[scope] = true
183+
}
184+
185+
// Find missing scopes (required but not in token)
186+
var missingScopes []string
187+
for _, scope := range requiredScopes {
188+
// Use scope hierarchy to check if token has equivalent parent scope
189+
if !scopes.HasRequiredScopes(tokenScopes, []string{scope}) {
190+
missingScopes = append(missingScopes, scope)
191+
}
192+
}
193+
194+
// Find extra scopes (in token but not covering any required scope)
195+
// A token scope is "extra" only if:
196+
// 1. It's not directly in the required set, AND
197+
// 2. None of the required scopes would be satisfied by this token scope, AND
198+
// 3. This token scope is not a child of any required scope
199+
var extraScopes []string
200+
for _, tokenScope := range tokenScopes {
201+
if requiredSet[tokenScope] {
202+
// Directly required, not extra
203+
continue
204+
}
205+
206+
// Check if this token scope covers any required scope
207+
coversAnyRequired := false
208+
for _, reqScope := range requiredScopes {
209+
// Check if tokenScope would satisfy reqScope
210+
if scopes.HasRequiredScopes([]string{tokenScope}, []string{reqScope}) {
211+
coversAnyRequired = true
212+
break
213+
}
214+
}
215+
216+
// Check if this token scope is covered by any required scope (i.e., it's a subset)
217+
isCoveredByRequired := false
218+
for _, reqScope := range requiredScopes {
219+
// Check if reqScope would satisfy tokenScope
220+
if scopes.HasRequiredScopes([]string{reqScope}, []string{tokenScope}) {
221+
isCoveredByRequired = true
222+
break
223+
}
224+
}
225+
226+
// Only mark as extra if it doesn't cover any required scope AND isn't covered by any required scope
227+
if !coversAnyRequired && !isCoveredByRequired {
228+
extraScopes = append(extraScopes, tokenScope)
229+
}
230+
}
231+
232+
sort.Strings(missingScopes)
233+
sort.Strings(extraScopes)
234+
235+
return ScopeComparison{
236+
TokenScopes: tokenScopes,
237+
RequiredScopes: requiredScopes,
238+
MissingScopes: missingScopes,
239+
ExtraScopes: extraScopes,
240+
HasAllRequired: len(missingScopes) == 0,
241+
}
242+
}
243+
244+
func outputCompareJSON(output CompareOutput) error {
245+
encoder := json.NewEncoder(os.Stdout)
246+
encoder.SetIndent("", " ")
247+
return encoder.Encode(output)
248+
}
249+
250+
func outputCompareText(output CompareOutput) error {
251+
fmt.Println("PAT Scope Comparison")
252+
fmt.Println("====================")
253+
fmt.Println()
254+
255+
comparison := output.Comparison
256+
257+
// Token scopes section
258+
fmt.Println("Token Scopes:")
259+
if len(comparison.TokenScopes) == 0 {
260+
fmt.Println(" (no scopes - might be a fine-grained PAT)")
261+
} else {
262+
for _, scope := range comparison.TokenScopes {
263+
fmt.Printf(" • %s\n", scope)
264+
}
265+
}
266+
fmt.Println()
267+
268+
// Required scopes section
269+
fmt.Println("Required Scopes:")
270+
if len(comparison.RequiredScopes) == 0 {
271+
fmt.Println(" (no scopes required)")
272+
} else {
273+
for _, scope := range comparison.RequiredScopes {
274+
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
275+
}
276+
}
277+
fmt.Println()
278+
279+
// Comparison result
280+
fmt.Println("Comparison Result:")
281+
if comparison.HasAllRequired {
282+
fmt.Println(" ✓ Token has all required scopes!")
283+
} else {
284+
fmt.Println(" ✗ Token is missing required scopes")
285+
}
286+
fmt.Println()
287+
288+
// Missing scopes
289+
if len(comparison.MissingScopes) > 0 {
290+
fmt.Println("Missing Scopes (need to add):")
291+
for _, scope := range comparison.MissingScopes {
292+
fmt.Printf(" ✗ %s\n", formatScopeDisplay(scope))
293+
}
294+
fmt.Println()
295+
}
296+
297+
// Extra scopes
298+
if len(comparison.ExtraScopes) > 0 {
299+
fmt.Println("Extra Scopes (not required but granted):")
300+
for _, scope := range comparison.ExtraScopes {
301+
fmt.Printf(" • %s\n", scope)
302+
}
303+
fmt.Println()
304+
}
305+
306+
// Configuration info
307+
fmt.Printf("Configuration: %d toolset(s), read-only=%v\n", len(output.EnabledToolsets), output.ReadOnly)
308+
fmt.Printf("Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", "))
309+
310+
return nil
311+
}

0 commit comments

Comments
 (0)