Skip to content

Commit 9be3315

Browse files
Add list_scopes.go implementation file
Co-authored-by: SamMorrowDrums <[email protected]>
1 parent a1885cc commit 9be3315

File tree

1 file changed

+315
-0
lines changed

1 file changed

+315
-0
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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/inventory"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/spf13/cobra"
15+
"github.com/spf13/viper"
16+
)
17+
18+
// ToolScopeInfo contains scope information for a single tool.
19+
type ToolScopeInfo struct {
20+
Name string `json:"name"`
21+
Toolset string `json:"toolset"`
22+
ReadOnly bool `json:"read_only"`
23+
RequiredScopes []string `json:"required_scopes"`
24+
AcceptedScopes []string `json:"accepted_scopes,omitempty"`
25+
}
26+
27+
// ScopesOutput is the full output structure for the list-scopes command.
28+
type ScopesOutput struct {
29+
Tools []ToolScopeInfo `json:"tools"`
30+
UniqueScopes []string `json:"unique_scopes"`
31+
ScopesByTool map[string][]string `json:"scopes_by_tool"`
32+
ToolsByScope map[string][]string `json:"tools_by_scope"`
33+
EnabledToolsets []string `json:"enabled_toolsets"`
34+
ReadOnly bool `json:"read_only"`
35+
}
36+
37+
var listScopesCmd = &cobra.Command{
38+
Use: "list-scopes",
39+
Short: "List required OAuth scopes for enabled tools",
40+
Long: `List the required OAuth scopes for all enabled tools.
41+
42+
This command creates an inventory based on the same flags as the stdio command
43+
and outputs the required OAuth scopes for each enabled tool. This is useful for
44+
determining what scopes a token needs to use specific tools.
45+
46+
The output format can be controlled with the --output flag:
47+
- text (default): Human-readable text output
48+
- json: JSON output for programmatic use
49+
- summary: Just the unique scopes needed
50+
51+
Examples:
52+
# List scopes for default toolsets
53+
github-mcp-server list-scopes
54+
55+
# List scopes for specific toolsets
56+
github-mcp-server list-scopes --toolsets=repos,issues,pull_requests
57+
58+
# List scopes for all toolsets
59+
github-mcp-server list-scopes --toolsets=all
60+
61+
# Output as JSON
62+
github-mcp-server list-scopes --output=json
63+
64+
# Just show unique scopes needed
65+
github-mcp-server list-scopes --output=summary`,
66+
RunE: func(_ *cobra.Command, _ []string) error {
67+
return runListScopes()
68+
},
69+
}
70+
71+
func init() {
72+
listScopesCmd.Flags().StringP("output", "o", "text", "Output format: text, json, or summary")
73+
_ = viper.BindPFlag("list-scopes-output", listScopesCmd.Flags().Lookup("output"))
74+
75+
rootCmd.AddCommand(listScopesCmd)
76+
}
77+
78+
func runListScopes() error {
79+
// Get toolsets configuration (same logic as stdio command)
80+
var enabledToolsets []string
81+
if viper.IsSet("toolsets") {
82+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
83+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
84+
}
85+
}
86+
// else: enabledToolsets stays nil, meaning "use defaults"
87+
88+
// Get specific tools (similar to toolsets)
89+
var enabledTools []string
90+
if viper.IsSet("tools") {
91+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
92+
return fmt.Errorf("failed to unmarshal tools: %w", err)
93+
}
94+
}
95+
96+
readOnly := viper.GetBool("read-only")
97+
outputFormat := viper.GetString("list-scopes-output")
98+
99+
// Create translation helper
100+
t, _ := translations.TranslationHelper()
101+
102+
// Build inventory using the same logic as the stdio server
103+
inventoryBuilder := github.NewInventory(t).
104+
WithReadOnly(readOnly)
105+
106+
// Configure toolsets (same as stdio)
107+
if enabledToolsets != nil {
108+
inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets)
109+
}
110+
111+
// Configure specific tools
112+
if len(enabledTools) > 0 {
113+
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
114+
}
115+
116+
inv := inventoryBuilder.Build()
117+
118+
// Collect all tools and their scopes
119+
output := collectToolScopes(inv, readOnly)
120+
121+
// Output based on format
122+
switch outputFormat {
123+
case "json":
124+
return outputJSON(output)
125+
case "summary":
126+
return outputSummary(output)
127+
default:
128+
return outputText(output)
129+
}
130+
}
131+
132+
func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput {
133+
var tools []ToolScopeInfo
134+
scopeSet := make(map[string]bool)
135+
scopesByTool := make(map[string][]string)
136+
toolsByScope := make(map[string][]string)
137+
138+
// Get all available tools from the inventory
139+
// Use context.Background() for feature flag evaluation
140+
availableTools := inv.AvailableTools(context.Background())
141+
142+
for _, serverTool := range availableTools {
143+
tool := serverTool.Tool
144+
145+
// Get scope information directly from ServerTool
146+
requiredScopes := serverTool.RequiredScopes
147+
acceptedScopes := serverTool.AcceptedScopes
148+
149+
// Determine if tool is read-only
150+
isReadOnly := serverTool.IsReadOnly()
151+
152+
toolInfo := ToolScopeInfo{
153+
Name: tool.Name,
154+
Toolset: string(serverTool.Toolset.ID),
155+
ReadOnly: isReadOnly,
156+
RequiredScopes: requiredScopes,
157+
AcceptedScopes: acceptedScopes,
158+
}
159+
tools = append(tools, toolInfo)
160+
161+
// Track unique scopes
162+
for _, s := range requiredScopes {
163+
scopeSet[s] = true
164+
toolsByScope[s] = append(toolsByScope[s], tool.Name)
165+
}
166+
167+
// Track scopes by tool
168+
scopesByTool[tool.Name] = requiredScopes
169+
}
170+
171+
// Sort tools by name
172+
sort.Slice(tools, func(i, j int) bool {
173+
return tools[i].Name < tools[j].Name
174+
})
175+
176+
// Get unique scopes as sorted slice
177+
var uniqueScopes []string
178+
for s := range scopeSet {
179+
uniqueScopes = append(uniqueScopes, s)
180+
}
181+
sort.Strings(uniqueScopes)
182+
183+
// Sort tools within each scope
184+
for scope := range toolsByScope {
185+
sort.Strings(toolsByScope[scope])
186+
}
187+
188+
// Get enabled toolsets as string slice
189+
toolsetIDs := inv.ToolsetIDs()
190+
toolsetIDStrs := make([]string, len(toolsetIDs))
191+
for i, id := range toolsetIDs {
192+
toolsetIDStrs[i] = string(id)
193+
}
194+
195+
return ScopesOutput{
196+
Tools: tools,
197+
UniqueScopes: uniqueScopes,
198+
ScopesByTool: scopesByTool,
199+
ToolsByScope: toolsByScope,
200+
EnabledToolsets: toolsetIDStrs,
201+
ReadOnly: readOnly,
202+
}
203+
}
204+
205+
func outputJSON(output ScopesOutput) error {
206+
encoder := json.NewEncoder(os.Stdout)
207+
encoder.SetIndent("", " ")
208+
return encoder.Encode(output)
209+
}
210+
211+
func outputSummary(output ScopesOutput) error {
212+
if len(output.UniqueScopes) == 0 {
213+
fmt.Println("No OAuth scopes required for enabled tools.")
214+
return nil
215+
}
216+
217+
fmt.Println("Required OAuth scopes for enabled tools:")
218+
fmt.Println()
219+
for _, scope := range output.UniqueScopes {
220+
if scope == "" {
221+
fmt.Println(" (no scope required for public read access)")
222+
} else {
223+
fmt.Printf(" %s\n", scope)
224+
}
225+
}
226+
fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes))
227+
return nil
228+
}
229+
230+
func outputText(output ScopesOutput) error {
231+
fmt.Printf("OAuth Scopes for Enabled Tools\n")
232+
fmt.Printf("==============================\n\n")
233+
234+
fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", "))
235+
fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly)
236+
237+
// Group tools by toolset
238+
toolsByToolset := make(map[string][]ToolScopeInfo)
239+
for _, tool := range output.Tools {
240+
toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool)
241+
}
242+
243+
// Get sorted toolset names
244+
var toolsetNames []string
245+
for name := range toolsByToolset {
246+
toolsetNames = append(toolsetNames, name)
247+
}
248+
sort.Strings(toolsetNames)
249+
250+
for _, toolsetName := range toolsetNames {
251+
tools := toolsByToolset[toolsetName]
252+
fmt.Printf("## %s\n\n", formatToolsetNameForOutput(toolsetName))
253+
254+
for _, tool := range tools {
255+
rwIndicator := "📝"
256+
if tool.ReadOnly {
257+
rwIndicator = "👁"
258+
}
259+
260+
scopeStr := "(no scope required)"
261+
if len(tool.RequiredScopes) > 0 {
262+
scopeStr = strings.Join(tool.RequiredScopes, ", ")
263+
}
264+
265+
fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
266+
}
267+
fmt.Println()
268+
}
269+
270+
// Summary
271+
fmt.Println("## Summary")
272+
fmt.Println()
273+
if len(output.UniqueScopes) == 0 {
274+
fmt.Println("No OAuth scopes required for enabled tools.")
275+
} else {
276+
fmt.Println("Unique scopes required:")
277+
for _, scope := range output.UniqueScopes {
278+
if scope == "" {
279+
fmt.Println(" • (no scope - public read access)")
280+
} else {
281+
fmt.Printf(" • %s\n", scope)
282+
}
283+
}
284+
}
285+
fmt.Printf("\nTotal: %d tools, %d unique scopes\n", len(output.Tools), len(output.UniqueScopes))
286+
287+
// Legend
288+
fmt.Println("\nLegend: 👁 = read-only, 📝 = read-write")
289+
290+
return nil
291+
}
292+
293+
func formatToolsetNameForOutput(name string) string {
294+
switch name {
295+
case "pull_requests":
296+
return "Pull Requests"
297+
case "repos":
298+
return "Repositories"
299+
case "code_security":
300+
return "Code Security"
301+
case "secret_protection":
302+
return "Secret Protection"
303+
case "orgs":
304+
return "Organizations"
305+
default:
306+
// Capitalize first letter and replace underscores with spaces
307+
parts := strings.Split(name, "_")
308+
for i, part := range parts {
309+
if len(part) > 0 {
310+
parts[i] = strings.ToUpper(string(part[0])) + part[1:]
311+
}
312+
}
313+
return strings.Join(parts, " ")
314+
}
315+
}

0 commit comments

Comments
 (0)