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

Large diffs are not rendered by default.

78 changes: 49 additions & 29 deletions cmd/github-mcp-server/generate_docs.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,21 +219,10 @@ 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 policy := formatScopePolicy(tool.ScopePolicy); policy != "" {
fmt.Fprintf(buf, " - **OAuth Scope Policy**: %s\n", policy)
if challenge := preferredChallengeScopes(tool.ScopePolicy); scopePolicyNeedsChallengeDetail(tool.ScopePolicy) && len(challenge) > 0 {
fmt.Fprintf(buf, " - **Preferred OAuth Challenge**: `%s`\n", strings.Join(challenge, "`, `"))
}
}

Expand DownExpand Up@@ -322,26 +311,57 @@ 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
func formatScopePolicy(policy inventory.ScopePolicy) string {
paths := make([]string, 0, len(policy.AnyOf))
for _, path := range policy.AnyOf {
requirements := make([]string, 0, len(path.AllOf))
for _, requirement := range path.AllOf {
alternatives := requirement.AnyOf
if len(alternatives) == 0 && requirement.ChallengeScope != "" {
alternatives = []string{requirement.ChallengeScope}
}
quoted := make([]string, len(alternatives))
for i, scope := range alternatives {
quoted[i] = "`" + scope + "`"
}
if len(quoted) > 1 {
requirements = append(requirements, "("+strings.Join(quoted, " OR ")+")")
} else if len(quoted) == 1 {
requirements = append(requirements, quoted[0])
}
}
if len(requirements) > 0 {
paths = append(paths, strings.Join(requirements, " AND "))
}
}
return strings.Join(paths, " OR ")
}

// Create a map for quick lookup
aMap := make(map[string]bool, len(a))
for _, scope := range a {
aMap[scope] = true
func preferredChallengeScopes(policy inventory.ScopePolicy) []string {
if len(policy.AnyOf) == 0 {
return nil
}

// Check if all elements in b are in a
for _, scope := range b {
if !aMap[scope] {
return false
result := make([]string, 0, len(policy.AnyOf[0].AllOf))
for _, requirement := range policy.AnyOf[0].AllOf {
if requirement.ChallengeScope != "" && !slices.Contains(result, requirement.ChallengeScope) {
result = append(result, requirement.ChallengeScope)
}
}
return result
}

return true
func scopePolicyNeedsChallengeDetail(policy inventory.ScopePolicy) bool {
if len(policy.AnyOf) > 1 {
return true
}
for _, path := range policy.AnyOf {
for _, requirement := range path.AllOf {
if len(requirement.AnyOf) != 1 || requirement.AnyOf[0] != requirement.ChallengeScope {
return true
}
}
}
return false
}

// indentMultilineDescription adds the specified indent to all lines after the first line.
Expand Down
63 changes: 37 additions & 26 deletions cmd/github-mcp-server/list_scopes.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"slices"
"sort"
"strings"

Expand All@@ -17,11 +18,11 @@ 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"`
ScopePolicy inventory.ScopePolicy `json:"scope_policy"`
ChallengeScopes []string `json:"challenge_scopes,omitempty"`
}

// ScopesOutput is the full output structure for the list-scopes command.
Expand All@@ -36,12 +37,12 @@ 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 scope policies 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 authorization paths and preferred challenge scopes for each
enabled tool.

The output format can be controlled with the --output flag:
- text (default): Human-readable text output
Expand DownExpand Up@@ -153,30 +154,28 @@ 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 := allChallengeScopes(serverTool.ScopePolicy)

// 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,
ScopePolicy: serverTool.ScopePolicy,
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 DownExpand Up@@ -225,7 +224,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 +234,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 Scope Policies 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 DownExpand Up@@ -265,8 +264,8 @@ func outputText(output ScopesOutput) error {
}

scopeStr := "(no scope required)"
if len(tool.RequiredScopes) > 0 {
scopeStr = strings.Join(tool.RequiredScopes, ", ")
if policy := formatScopePolicy(tool.ScopePolicy); policy != "" {
scopeStr = policy
}

fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
Expand All@@ -278,9 +277,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 preferred challenge scopes:")
for _, scope := range output.UniqueScopes {
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
}
Expand All@@ -292,3 +291,15 @@ func outputText(output ScopesOutput) error {

return nil
}

func allChallengeScopes(policy inventory.ScopePolicy) []string {
var result []string
for _, path := range policy.AnyOf {
for _, requirement := range path.AllOf {
if requirement.ChallengeScope != "" && !slices.Contains(result, requirement.ChallengeScope) {
result = append(result, requirement.ChallengeScope)
}
}
}
return result
}
18 changes: 9 additions & 9 deletions cmd/github-mcp-server/main_test.go
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -63,21 +64,20 @@ func TestWriteToolDocScopeSemantics(t *testing.T) {
want string
}{
{
name: "legacy multi-scope tools use any-of",
name: "alternative scope paths",
tool: inventory.ServerTool{
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
RequiredScopes: []string{"repo", "read:org"},
Tool: mcp.Tool{Name: "alternative", Annotations: &mcp.ToolAnnotations{Title: "Alternative"}},
ScopePolicy: scopes.AnyOfScopePolicy(scopes.Repo, scopes.ReadOrg),
},
want: "**Required OAuth Scopes (any of)**",
want: "`repo` OR (`admin:org` OR `read:org` OR `write:org`)",
},
{
name: "conjunctive scope groups use all-required",
name: "conjunctive requirements",
tool: inventory.ServerTool{
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
RequiredScopes: []string{"delete_repo", "repo"},
RequiredScopeGroups: [][]string{{"delete_repo"}, {"repo"}},
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
ScopePolicy: scopes.AllOfScopePolicy(scopes.DeleteRepo, scopes.Repo),
},
want: "**Required OAuth Scopes (all required)**",
want: "`delete_repo` AND `repo`",
},
}

Expand Down
Loading
Loading