Skip to content
Merged
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
185 changes: 84 additions & 101 deletions README.md

Large diffs are not rendered by default.

40 changes: 2 additions & 38 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,22 +219,8 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
// Tool name (no icon - section header already has the toolset icon)
fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title)

// OAuth scopes if present
if len(tool.RequiredScopes) > 0 {
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
switch {
case len(tool.RequiredScopeGroups) > 1:
fmt.Fprintf(buf, " - **Required OAuth Scopes (all required)**: %s\n", scopeList)
case len(tool.RequiredScopes) > 1:
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
default:
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
}

// Only show accepted scopes if they differ from required scopes
if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) {
fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `"))
}
if scopes := tool.ScopeAccess.Scopes; len(scopes) > 0 {
fmt.Fprintf(buf, " - **OAuth Challenge Scopes**: `%s`\n", strings.Join(scopes, "`, `"))
}

// MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag
Expand Down Expand Up @@ -322,28 +308,6 @@ func schemaTypeString(schema *jsonschema.Schema) string {
return strings.Join(types, " | ")
}

// scopesEqual checks if two scope slices contain the same elements (order-independent)
func scopesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}

// Create a map for quick lookup
aMap := make(map[string]bool, len(a))
for _, scope := range a {
aMap[scope] = true
}

// Check if all elements in b are in a
for _, scope := range b {
if !aMap[scope] {
return false
}
}

return true
}

// indentMultilineDescription adds the specified indent to all lines after the first line.
// This ensures that multi-line descriptions maintain proper markdown list formatting.
func indentMultilineDescription(description, indent string) string {
Expand Down
47 changes: 21 additions & 26 deletions cmd/github-mcp-server/list_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ import (

// ToolScopeInfo contains scope information for a single tool.
type ToolScopeInfo struct {
Name string `json:"name"`
Toolset string `json:"toolset"`
ReadOnly bool `json:"read_only"`
RequiredScopes []string `json:"required_scopes"`
AcceptedScopes []string `json:"accepted_scopes,omitempty"`
Name string `json:"name"`
Toolset string `json:"toolset"`
ReadOnly bool `json:"read_only"`
ChallengeScopes []string `json:"challenge_scopes,omitempty"`
}

// ScopesOutput is the full output structure for the list-scopes command.
Expand All @@ -36,12 +35,11 @@ type ScopesOutput struct {

var listScopesCmd = &cobra.Command{
Use: "list-scopes",
Short: "List required OAuth scopes for enabled tools",
Long: `List the required OAuth scopes for all enabled tools.
Short: "List OAuth scope policies for enabled tools",
Long: `List the OAuth challenge scopes for all enabled tools.

This command creates an inventory based on the same flags as the stdio command
and outputs the required OAuth scopes for each enabled tool. This is useful for
determining what scopes a token needs to use specific tools.
and outputs the scopes each enabled tool may request in an OAuth challenge.

The output format can be controlled with the --output flag:
- text (default): Human-readable text output
Expand Down Expand Up @@ -153,30 +151,27 @@ func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput {
for _, serverTool := range availableTools {
tool := serverTool.Tool

// Get scope information directly from ServerTool
requiredScopes := serverTool.RequiredScopes
acceptedScopes := serverTool.AcceptedScopes
challengeScopes := serverTool.ScopeAccess.Scopes

// Determine if tool is read-only
isReadOnly := serverTool.IsReadOnly()

toolInfo := ToolScopeInfo{
Name: tool.Name,
Toolset: string(serverTool.Toolset.ID),
ReadOnly: isReadOnly,
RequiredScopes: requiredScopes,
AcceptedScopes: acceptedScopes,
Name: tool.Name,
Toolset: string(serverTool.Toolset.ID),
ReadOnly: isReadOnly,
ChallengeScopes: challengeScopes,
}
tools = append(tools, toolInfo)

// Track unique scopes
for _, s := range requiredScopes {
for _, s := range challengeScopes {
scopeSet[s] = true
toolsByScope[s] = append(toolsByScope[s], tool.Name)
}

// Track scopes by tool
scopesByTool[tool.Name] = requiredScopes
scopesByTool[tool.Name] = challengeScopes
}

// Sort tools by name
Expand Down Expand Up @@ -225,7 +220,7 @@ func outputSummary(output ScopesOutput) error {
return nil
}

fmt.Println("Required OAuth scopes for enabled tools:")
fmt.Println("OAuth scope policies for enabled tools:")
fmt.Println()
for _, scope := range output.UniqueScopes {
fmt.Printf(" %s\n", formatScopeDisplay(scope))
Expand All @@ -235,8 +230,8 @@ func outputSummary(output ScopesOutput) error {
}

func outputText(output ScopesOutput) error {
fmt.Printf("OAuth Scopes for Enabled Tools\n")
fmt.Printf("==============================\n\n")
fmt.Printf("OAuth Challenge Scopes for Enabled Tools\n")
fmt.Printf("========================================\n\n")

fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", "))
fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly)
Expand Down Expand Up @@ -265,8 +260,8 @@ func outputText(output ScopesOutput) error {
}

scopeStr := "(no scope required)"
if len(tool.RequiredScopes) > 0 {
scopeStr = strings.Join(tool.RequiredScopes, ", ")
if len(tool.ChallengeScopes) > 0 {
scopeStr = strings.Join(tool.ChallengeScopes, ", ")
}

fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
Expand All @@ -278,9 +273,9 @@ func outputText(output ScopesOutput) error {
fmt.Println("## Summary")
fmt.Println()
if len(output.UniqueScopes) == 0 {
fmt.Println("No OAuth scopes required for enabled tools.")
fmt.Println("No OAuth scopes are used by enabled tools.")
} else {
fmt.Println("Unique scopes required:")
fmt.Println("Unique challenge scopes:")
for _, scope := range output.UniqueScopes {
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
}
Expand Down
38 changes: 8 additions & 30 deletions cmd/github-mcp-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/viper"
Expand Down Expand Up @@ -56,38 +57,15 @@ func TestAuthorizationServerConfigurationIsHTTPOnly(t *testing.T) {
assert.Equal(t, "https://oauth-proxy.example.com", viper.GetString("authorization-server"))
}

func TestWriteToolDocScopeSemantics(t *testing.T) {
tests := []struct {
name string
tool inventory.ServerTool
want string
}{
{
name: "legacy multi-scope tools use any-of",
tool: inventory.ServerTool{
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
RequiredScopes: []string{"repo", "read:org"},
},
want: "**Required OAuth Scopes (any of)**",
},
{
name: "conjunctive scope groups use all-required",
tool: inventory.ServerTool{
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
RequiredScopes: []string{"delete_repo", "repo"},
RequiredScopeGroups: [][]string{{"delete_repo"}, {"repo"}},
},
want: "**Required OAuth Scopes (all required)**",
},
func TestWriteToolDocScopes(t *testing.T) {
tool := inventory.ServerTool{
Tool: mcp.Tool{Name: "delete", Annotations: &mcp.ToolAnnotations{Title: "Delete"}},
ScopeAccess: scopes.RequireAll(scopes.DeleteRepo, scopes.Repo),
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
writeToolDoc(&buf, tt.tool)
assert.Contains(t, buf.String(), tt.want)
})
}
var buf strings.Builder
writeToolDoc(&buf, tool)
assert.Contains(t, buf.String(), "**OAuth Challenge Scopes**: `delete_repo`, `repo`")
}

func TestSchemaTypeString(t *testing.T) {
Expand Down
Loading
Loading