Skip to content

Commit ed5d498

Browse files
Merge branch 'main' into fix-resource-handler-deps-from-context
2 parents 2139a34 + 44d9e13 commit ed5d498

File tree

9 files changed

+477
-56
lines changed

9 files changed

+477
-56
lines changed

cmd/github-mcp-server/generate_docs.go

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -198,30 +198,6 @@ func generateToolsDoc(r *inventory.Inventory) string {
198198
return buf.String()
199199
}
200200

201-
func formatToolsetName(name string) string {
202-
switch name {
203-
case "pull_requests":
204-
return "Pull Requests"
205-
case "repos":
206-
return "Repositories"
207-
case "code_security":
208-
return "Code Security"
209-
case "secret_protection":
210-
return "Secret Protection"
211-
case "orgs":
212-
return "Organizations"
213-
default:
214-
// Fallback: capitalize first letter and replace underscores with spaces
215-
parts := strings.Split(name, "_")
216-
for i, part := range parts {
217-
if len(part) > 0 {
218-
parts[i] = strings.ToUpper(string(part[0])) + part[1:]
219-
}
220-
}
221-
return strings.Join(parts, " ")
222-
}
223-
}
224-
225201
func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
226202
// Tool name (no icon - section header already has the toolset icon)
227203
fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title)

cmd/github-mcp-server/helpers.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import "strings"
4+
5+
// formatToolsetName converts a toolset ID to a human-readable name.
6+
// Used by both generate_docs.go and list_scopes.go for consistent formatting.
7+
func formatToolsetName(name string) string {
8+
switch name {
9+
case "pull_requests":
10+
return "Pull Requests"
11+
case "repos":
12+
return "Repositories"
13+
case "code_security":
14+
return "Code Security"
15+
case "secret_protection":
16+
return "Secret Protection"
17+
case "orgs":
18+
return "Organizations"
19+
default:
20+
// Fallback: capitalize first letter and replace underscores with spaces
21+
parts := strings.Split(name, "_")
22+
for i, part := range parts {
23+
if len(part) > 0 {
24+
parts[i] = strings.ToUpper(string(part[0])) + part[1:]
25+
}
26+
}
27+
return strings.Join(parts, " ")
28+
}
29+
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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+
// formatScopeDisplay formats a scope string for display, handling empty scopes.
79+
func formatScopeDisplay(scope string) string {
80+
if scope == "" {
81+
return "(no scope required for public read access)"
82+
}
83+
return scope
84+
}
85+
86+
func runListScopes() error {
87+
// Get toolsets configuration (same logic as stdio command)
88+
var enabledToolsets []string
89+
if viper.IsSet("toolsets") {
90+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
91+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
92+
}
93+
}
94+
// else: enabledToolsets stays nil, meaning "use defaults"
95+
96+
// Get specific tools (similar to toolsets)
97+
var enabledTools []string
98+
if viper.IsSet("tools") {
99+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
100+
return fmt.Errorf("failed to unmarshal tools: %w", err)
101+
}
102+
}
103+
104+
readOnly := viper.GetBool("read-only")
105+
outputFormat := viper.GetString("list-scopes-output")
106+
107+
// Create translation helper
108+
t, _ := translations.TranslationHelper()
109+
110+
// Build inventory using the same logic as the stdio server
111+
inventoryBuilder := github.NewInventory(t).
112+
WithReadOnly(readOnly)
113+
114+
// Configure toolsets (same as stdio)
115+
if enabledToolsets != nil {
116+
inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets)
117+
}
118+
119+
// Configure specific tools
120+
if len(enabledTools) > 0 {
121+
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
122+
}
123+
124+
inv := inventoryBuilder.Build()
125+
126+
// Collect all tools and their scopes
127+
output := collectToolScopes(inv, readOnly)
128+
129+
// Output based on format
130+
switch outputFormat {
131+
case "json":
132+
return outputJSON(output)
133+
case "summary":
134+
return outputSummary(output)
135+
default:
136+
return outputText(output)
137+
}
138+
}
139+
140+
func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput {
141+
var tools []ToolScopeInfo
142+
scopeSet := make(map[string]bool)
143+
scopesByTool := make(map[string][]string)
144+
toolsByScope := make(map[string][]string)
145+
146+
// Get all available tools from the inventory
147+
// Use context.Background() for feature flag evaluation
148+
availableTools := inv.AvailableTools(context.Background())
149+
150+
for _, serverTool := range availableTools {
151+
tool := serverTool.Tool
152+
153+
// Get scope information directly from ServerTool
154+
requiredScopes := serverTool.RequiredScopes
155+
acceptedScopes := serverTool.AcceptedScopes
156+
157+
// Determine if tool is read-only
158+
isReadOnly := serverTool.IsReadOnly()
159+
160+
toolInfo := ToolScopeInfo{
161+
Name: tool.Name,
162+
Toolset: string(serverTool.Toolset.ID),
163+
ReadOnly: isReadOnly,
164+
RequiredScopes: requiredScopes,
165+
AcceptedScopes: acceptedScopes,
166+
}
167+
tools = append(tools, toolInfo)
168+
169+
// Track unique scopes
170+
for _, s := range requiredScopes {
171+
scopeSet[s] = true
172+
toolsByScope[s] = append(toolsByScope[s], tool.Name)
173+
}
174+
175+
// Track scopes by tool
176+
scopesByTool[tool.Name] = requiredScopes
177+
}
178+
179+
// Sort tools by name
180+
sort.Slice(tools, func(i, j int) bool {
181+
return tools[i].Name < tools[j].Name
182+
})
183+
184+
// Get unique scopes as sorted slice
185+
var uniqueScopes []string
186+
for s := range scopeSet {
187+
uniqueScopes = append(uniqueScopes, s)
188+
}
189+
sort.Strings(uniqueScopes)
190+
191+
// Sort tools within each scope
192+
for scope := range toolsByScope {
193+
sort.Strings(toolsByScope[scope])
194+
}
195+
196+
// Get enabled toolsets as string slice
197+
toolsetIDs := inv.ToolsetIDs()
198+
toolsetIDStrs := make([]string, len(toolsetIDs))
199+
for i, id := range toolsetIDs {
200+
toolsetIDStrs[i] = string(id)
201+
}
202+
203+
return ScopesOutput{
204+
Tools: tools,
205+
UniqueScopes: uniqueScopes,
206+
ScopesByTool: scopesByTool,
207+
ToolsByScope: toolsByScope,
208+
EnabledToolsets: toolsetIDStrs,
209+
ReadOnly: readOnly,
210+
}
211+
}
212+
213+
func outputJSON(output ScopesOutput) error {
214+
encoder := json.NewEncoder(os.Stdout)
215+
encoder.SetIndent("", " ")
216+
return encoder.Encode(output)
217+
}
218+
219+
func outputSummary(output ScopesOutput) error {
220+
if len(output.UniqueScopes) == 0 {
221+
fmt.Println("No OAuth scopes required for enabled tools.")
222+
return nil
223+
}
224+
225+
fmt.Println("Required OAuth scopes for enabled tools:")
226+
fmt.Println()
227+
for _, scope := range output.UniqueScopes {
228+
fmt.Printf(" %s\n", formatScopeDisplay(scope))
229+
}
230+
fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes))
231+
return nil
232+
}
233+
234+
func outputText(output ScopesOutput) error {
235+
fmt.Printf("OAuth Scopes for Enabled Tools\n")
236+
fmt.Printf("==============================\n\n")
237+
238+
fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", "))
239+
fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly)
240+
241+
// Group tools by toolset
242+
toolsByToolset := make(map[string][]ToolScopeInfo)
243+
for _, tool := range output.Tools {
244+
toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool)
245+
}
246+
247+
// Get sorted toolset names
248+
var toolsetNames []string
249+
for name := range toolsByToolset {
250+
toolsetNames = append(toolsetNames, name)
251+
}
252+
sort.Strings(toolsetNames)
253+
254+
for _, toolsetName := range toolsetNames {
255+
tools := toolsByToolset[toolsetName]
256+
fmt.Printf("## %s\n\n", formatToolsetName(toolsetName))
257+
258+
for _, tool := range tools {
259+
rwIndicator := "📝"
260+
if tool.ReadOnly {
261+
rwIndicator = "👁"
262+
}
263+
264+
scopeStr := "(no scope required)"
265+
if len(tool.RequiredScopes) > 0 {
266+
scopeStr = strings.Join(tool.RequiredScopes, ", ")
267+
}
268+
269+
fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
270+
}
271+
fmt.Println()
272+
}
273+
274+
// Summary
275+
fmt.Println("## Summary")
276+
fmt.Println()
277+
if len(output.UniqueScopes) == 0 {
278+
fmt.Println("No OAuth scopes required for enabled tools.")
279+
} else {
280+
fmt.Println("Unique scopes required:")
281+
for _, scope := range output.UniqueScopes {
282+
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
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+
}

0 commit comments

Comments
 (0)