Skip to content

Commit 96f14fd

Browse files
feat(auth): add OAuth scope policies
Model authorization as alternative paths with conjunctive requirements and per-requirement scope alternatives. Resolve call-specific policies from tool arguments for precise PAT filtering and OAuth challenges. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26e41558-43f9-42b2-8569-8489957c2b0a
1 parent a7c3b49 commit 96f14fd

51 files changed

Lines changed: 1252 additions & 972 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 101 additions & 101 deletions
Large diffs are not rendered by default.

cmd/github-mcp-server/generate_docs.go

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -219,21 +219,10 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
219219
// Tool name (no icon - section header already has the toolset icon)
220220
fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title)
221221

222-
// OAuth scopes if present
223-
if len(tool.RequiredScopes) > 0 {
224-
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
225-
switch {
226-
case len(tool.RequiredScopeGroups) > 1:
227-
fmt.Fprintf(buf, " - **Required OAuth Scopes (all required)**: %s\n", scopeList)
228-
case len(tool.RequiredScopes) > 1:
229-
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
230-
default:
231-
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
232-
}
233-
234-
// Only show accepted scopes if they differ from required scopes
235-
if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) {
236-
fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `"))
222+
if policy := formatScopePolicy(tool.ScopePolicy); policy != "" {
223+
fmt.Fprintf(buf, " - **OAuth Scope Policy**: %s\n", policy)
224+
if challenge := preferredChallengeScopes(tool.ScopePolicy); scopePolicyNeedsChallengeDetail(tool.ScopePolicy) && len(challenge) > 0 {
225+
fmt.Fprintf(buf, " - **Preferred OAuth Challenge**: `%s`\n", strings.Join(challenge, "`, `"))
237226
}
238227
}
239228

@@ -322,26 +311,57 @@ func schemaTypeString(schema *jsonschema.Schema) string {
322311
return strings.Join(types, " | ")
323312
}
324313

325-
// scopesEqual checks if two scope slices contain the same elements (order-independent)
326-
func scopesEqual(a, b []string) bool {
327-
if len(a) != len(b) {
328-
return false
314+
func formatScopePolicy(policy inventory.ScopePolicy) string {
315+
paths := make([]string, 0, len(policy.AnyOf))
316+
for _, path := range policy.AnyOf {
317+
requirements := make([]string, 0, len(path.AllOf))
318+
for _, requirement := range path.AllOf {
319+
alternatives := requirement.AnyOf
320+
if len(alternatives) == 0 && requirement.ChallengeScope != "" {
321+
alternatives = []string{requirement.ChallengeScope}
322+
}
323+
quoted := make([]string, len(alternatives))
324+
for i, scope := range alternatives {
325+
quoted[i] = "`" + scope + "`"
326+
}
327+
if len(quoted) > 1 {
328+
requirements = append(requirements, "("+strings.Join(quoted, " OR ")+")")
329+
} else if len(quoted) == 1 {
330+
requirements = append(requirements, quoted[0])
331+
}
332+
}
333+
if len(requirements) > 0 {
334+
paths = append(paths, strings.Join(requirements, " AND "))
335+
}
329336
}
337+
return strings.Join(paths, " OR ")
338+
}
330339

331-
// Create a map for quick lookup
332-
aMap := make(map[string]bool, len(a))
333-
for _, scope := range a {
334-
aMap[scope] = true
340+
func preferredChallengeScopes(policy inventory.ScopePolicy) []string {
341+
if len(policy.AnyOf) == 0 {
342+
return nil
335343
}
336-
337-
// Check if all elements in b are in a
338-
for _, scope := range b {
339-
if !aMap[scope] {
340-
return false
344+
result := make([]string, 0, len(policy.AnyOf[0].AllOf))
345+
for _, requirement := range policy.AnyOf[0].AllOf {
346+
if requirement.ChallengeScope != "" && !slices.Contains(result, requirement.ChallengeScope) {
347+
result = append(result, requirement.ChallengeScope)
341348
}
342349
}
350+
return result
351+
}
343352

344-
return true
353+
func scopePolicyNeedsChallengeDetail(policy inventory.ScopePolicy) bool {
354+
if len(policy.AnyOf) > 1 {
355+
return true
356+
}
357+
for _, path := range policy.AnyOf {
358+
for _, requirement := range path.AllOf {
359+
if len(requirement.AnyOf) != 1 || requirement.AnyOf[0] != requirement.ChallengeScope {
360+
return true
361+
}
362+
}
363+
}
364+
return false
345365
}
346366

347367
// indentMultilineDescription adds the specified indent to all lines after the first line.

cmd/github-mcp-server/list_scopes.go

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"slices"
89
"sort"
910
"strings"
1011

@@ -17,11 +18,11 @@ import (
1718

1819
// ToolScopeInfo contains scope information for a single tool.
1920
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"`
21+
Name string `json:"name"`
22+
Toolset string `json:"toolset"`
23+
ReadOnly bool `json:"read_only"`
24+
ScopePolicy inventory.ScopePolicy `json:"scope_policy"`
25+
ChallengeScopes []string `json:"challenge_scopes,omitempty"`
2526
}
2627

2728
// ScopesOutput is the full output structure for the list-scopes command.
@@ -36,12 +37,12 @@ type ScopesOutput struct {
3637

3738
var listScopesCmd = &cobra.Command{
3839
Use: "list-scopes",
39-
Short: "List required OAuth scopes for enabled tools",
40-
Long: `List the required OAuth scopes for all enabled tools.
40+
Short: "List OAuth scope policies for enabled tools",
41+
Long: `List the OAuth scope policies for all enabled tools.
4142
4243
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.
44+
and outputs the authorization paths and preferred challenge scopes for each
45+
enabled tool.
4546
4647
The output format can be controlled with the --output flag:
4748
- text (default): Human-readable text output
@@ -153,30 +154,28 @@ func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput {
153154
for _, serverTool := range availableTools {
154155
tool := serverTool.Tool
155156

156-
// Get scope information directly from ServerTool
157-
requiredScopes := serverTool.RequiredScopes
158-
acceptedScopes := serverTool.AcceptedScopes
157+
challengeScopes := allChallengeScopes(serverTool.ScopePolicy)
159158

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

163162
toolInfo := ToolScopeInfo{
164-
Name: tool.Name,
165-
Toolset: string(serverTool.Toolset.ID),
166-
ReadOnly: isReadOnly,
167-
RequiredScopes: requiredScopes,
168-
AcceptedScopes: acceptedScopes,
163+
Name: tool.Name,
164+
Toolset: string(serverTool.Toolset.ID),
165+
ReadOnly: isReadOnly,
166+
ScopePolicy: serverTool.ScopePolicy,
167+
ChallengeScopes: challengeScopes,
169168
}
170169
tools = append(tools, toolInfo)
171170

172171
// Track unique scopes
173-
for _, s := range requiredScopes {
172+
for _, s := range challengeScopes {
174173
scopeSet[s] = true
175174
toolsByScope[s] = append(toolsByScope[s], tool.Name)
176175
}
177176

178177
// Track scopes by tool
179-
scopesByTool[tool.Name] = requiredScopes
178+
scopesByTool[tool.Name] = challengeScopes
180179
}
181180

182181
// Sort tools by name
@@ -225,7 +224,7 @@ func outputSummary(output ScopesOutput) error {
225224
return nil
226225
}
227226

228-
fmt.Println("Required OAuth scopes for enabled tools:")
227+
fmt.Println("OAuth scope policies for enabled tools:")
229228
fmt.Println()
230229
for _, scope := range output.UniqueScopes {
231230
fmt.Printf(" %s\n", formatScopeDisplay(scope))
@@ -235,8 +234,8 @@ func outputSummary(output ScopesOutput) error {
235234
}
236235

237236
func outputText(output ScopesOutput) error {
238-
fmt.Printf("OAuth Scopes for Enabled Tools\n")
239-
fmt.Printf("==============================\n\n")
237+
fmt.Printf("OAuth Scope Policies for Enabled Tools\n")
238+
fmt.Printf("======================================\n\n")
240239

241240
fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", "))
242241
fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly)
@@ -265,8 +264,8 @@ func outputText(output ScopesOutput) error {
265264
}
266265

267266
scopeStr := "(no scope required)"
268-
if len(tool.RequiredScopes) > 0 {
269-
scopeStr = strings.Join(tool.RequiredScopes, ", ")
267+
if policy := formatScopePolicy(tool.ScopePolicy); policy != "" {
268+
scopeStr = policy
270269
}
271270

272271
fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
@@ -278,9 +277,9 @@ func outputText(output ScopesOutput) error {
278277
fmt.Println("## Summary")
279278
fmt.Println()
280279
if len(output.UniqueScopes) == 0 {
281-
fmt.Println("No OAuth scopes required for enabled tools.")
280+
fmt.Println("No OAuth scopes are used by enabled tools.")
282281
} else {
283-
fmt.Println("Unique scopes required:")
282+
fmt.Println("Unique preferred challenge scopes:")
284283
for _, scope := range output.UniqueScopes {
285284
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
286285
}
@@ -292,3 +291,15 @@ func outputText(output ScopesOutput) error {
292291

293292
return nil
294293
}
294+
295+
func allChallengeScopes(policy inventory.ScopePolicy) []string {
296+
var result []string
297+
for _, path := range policy.AnyOf {
298+
for _, requirement := range path.AllOf {
299+
if requirement.ChallengeScope != "" && !slices.Contains(result, requirement.ChallengeScope) {
300+
result = append(result, requirement.ChallengeScope)
301+
}
302+
}
303+
}
304+
return result
305+
}

cmd/github-mcp-server/main_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
"github.com/github/github-mcp-server/pkg/inventory"
10+
"github.com/github/github-mcp-server/pkg/scopes"
1011
"github.com/google/jsonschema-go/jsonschema"
1112
"github.com/modelcontextprotocol/go-sdk/mcp"
1213
"github.com/spf13/viper"
@@ -63,21 +64,20 @@ func TestWriteToolDocScopeSemantics(t *testing.T) {
6364
want string
6465
}{
6566
{
66-
name: "legacy multi-scope tools use any-of",
67+
name: "alternative scope paths",
6768
tool: inventory.ServerTool{
68-
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
69-
RequiredScopes: []string{"repo", "read:org"},
69+
Tool: mcp.Tool{Name: "alternative", Annotations: &mcp.ToolAnnotations{Title: "Alternative"}},
70+
ScopePolicy: scopes.AnyOfScopePolicy(scopes.Repo, scopes.ReadOrg),
7071
},
71-
want: "**Required OAuth Scopes (any of)**",
72+
want: "`repo` OR (`admin:org` OR `read:org` OR `write:org`)",
7273
},
7374
{
74-
name: "conjunctive scope groups use all-required",
75+
name: "conjunctive requirements",
7576
tool: inventory.ServerTool{
76-
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
77-
RequiredScopes: []string{"delete_repo", "repo"},
78-
RequiredScopeGroups: [][]string{{"delete_repo"}, {"repo"}},
77+
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
78+
ScopePolicy: scopes.AllOfScopePolicy(scopes.DeleteRepo, scopes.Repo),
7979
},
80-
want: "**Required OAuth Scopes (all required)**",
80+
want: "`delete_repo` AND `repo`",
8181
},
8282
}
8383

0 commit comments

Comments
 (0)