Skip to content

Commit 1850911

Browse files
feat(auth): add per-call OAuth scope checks
Let each tool define fixed-token visibility and return the exact scopes for an OAuth challenge based on the current call. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26e41558-43f9-42b2-8569-8489957c2b0a
1 parent 64a49f3 commit 1850911

51 files changed

Lines changed: 1028 additions & 1233 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: 84 additions & 101 deletions
Large diffs are not rendered by default.

cmd/github-mcp-server/generate_docs.go

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -219,22 +219,8 @@ 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, "`, `"))
237-
}
222+
if scopes := tool.ScopeAccess.Scopes; len(scopes) > 0 {
223+
fmt.Fprintf(buf, " - **OAuth Challenge Scopes**: `%s`\n", strings.Join(scopes, "`, `"))
238224
}
239225

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

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
329-
}
330-
331-
// Create a map for quick lookup
332-
aMap := make(map[string]bool, len(a))
333-
for _, scope := range a {
334-
aMap[scope] = true
335-
}
336-
337-
// Check if all elements in b are in a
338-
for _, scope := range b {
339-
if !aMap[scope] {
340-
return false
341-
}
342-
}
343-
344-
return true
345-
}
346-
347311
// indentMultilineDescription adds the specified indent to all lines after the first line.
348312
// This ensures that multi-line descriptions maintain proper markdown list formatting.
349313
func indentMultilineDescription(description, indent string) string {

cmd/github-mcp-server/list_scopes.go

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ import (
1717

1818
// ToolScopeInfo contains scope information for a single tool.
1919
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"`
20+
Name string `json:"name"`
21+
Toolset string `json:"toolset"`
22+
ReadOnly bool `json:"read_only"`
23+
ChallengeScopes []string `json:"challenge_scopes,omitempty"`
2524
}
2625

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

3736
var listScopesCmd = &cobra.Command{
3837
Use: "list-scopes",
39-
Short: "List required OAuth scopes for enabled tools",
40-
Long: `List the required OAuth scopes for all enabled tools.
38+
Short: "List OAuth scope policies for enabled tools",
39+
Long: `List the OAuth challenge scopes for all enabled tools.
4140
4241
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.
42+
and outputs the scopes each enabled tool may request in an OAuth challenge.
4543
4644
The output format can be controlled with the --output flag:
4745
- text (default): Human-readable text output
@@ -153,30 +151,27 @@ func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput {
153151
for _, serverTool := range availableTools {
154152
tool := serverTool.Tool
155153

156-
// Get scope information directly from ServerTool
157-
requiredScopes := serverTool.RequiredScopes
158-
acceptedScopes := serverTool.AcceptedScopes
154+
challengeScopes := serverTool.ScopeAccess.Scopes
159155

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

163159
toolInfo := ToolScopeInfo{
164-
Name: tool.Name,
165-
Toolset: string(serverTool.Toolset.ID),
166-
ReadOnly: isReadOnly,
167-
RequiredScopes: requiredScopes,
168-
AcceptedScopes: acceptedScopes,
160+
Name: tool.Name,
161+
Toolset: string(serverTool.Toolset.ID),
162+
ReadOnly: isReadOnly,
163+
ChallengeScopes: challengeScopes,
169164
}
170165
tools = append(tools, toolInfo)
171166

172167
// Track unique scopes
173-
for _, s := range requiredScopes {
168+
for _, s := range challengeScopes {
174169
scopeSet[s] = true
175170
toolsByScope[s] = append(toolsByScope[s], tool.Name)
176171
}
177172

178173
// Track scopes by tool
179-
scopesByTool[tool.Name] = requiredScopes
174+
scopesByTool[tool.Name] = challengeScopes
180175
}
181176

182177
// Sort tools by name
@@ -225,7 +220,7 @@ func outputSummary(output ScopesOutput) error {
225220
return nil
226221
}
227222

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

237232
func outputText(output ScopesOutput) error {
238-
fmt.Printf("OAuth Scopes for Enabled Tools\n")
239-
fmt.Printf("==============================\n\n")
233+
fmt.Printf("OAuth Challenge Scopes for Enabled Tools\n")
234+
fmt.Printf("========================================\n\n")
240235

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

267262
scopeStr := "(no scope required)"
268-
if len(tool.RequiredScopes) > 0 {
269-
scopeStr = strings.Join(tool.RequiredScopes, ", ")
263+
if len(tool.ChallengeScopes) > 0 {
264+
scopeStr = strings.Join(tool.ChallengeScopes, ", ")
270265
}
271266

272267
fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
@@ -278,9 +273,9 @@ func outputText(output ScopesOutput) error {
278273
fmt.Println("## Summary")
279274
fmt.Println()
280275
if len(output.UniqueScopes) == 0 {
281-
fmt.Println("No OAuth scopes required for enabled tools.")
276+
fmt.Println("No OAuth scopes are used by enabled tools.")
282277
} else {
283-
fmt.Println("Unique scopes required:")
278+
fmt.Println("Unique challenge scopes:")
284279
for _, scope := range output.UniqueScopes {
285280
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
286281
}

cmd/github-mcp-server/main_test.go

Lines changed: 8 additions & 30 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"
@@ -56,38 +57,15 @@ func TestAuthorizationServerConfigurationIsHTTPOnly(t *testing.T) {
5657
assert.Equal(t, "https://oauth-proxy.example.com", viper.GetString("authorization-server"))
5758
}
5859

59-
func TestWriteToolDocScopeSemantics(t *testing.T) {
60-
tests := []struct {
61-
name string
62-
tool inventory.ServerTool
63-
want string
64-
}{
65-
{
66-
name: "legacy multi-scope tools use any-of",
67-
tool: inventory.ServerTool{
68-
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
69-
RequiredScopes: []string{"repo", "read:org"},
70-
},
71-
want: "**Required OAuth Scopes (any of)**",
72-
},
73-
{
74-
name: "conjunctive scope groups use all-required",
75-
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"}},
79-
},
80-
want: "**Required OAuth Scopes (all required)**",
81-
},
60+
func TestWriteToolDocScopes(t *testing.T) {
61+
tool := inventory.ServerTool{
62+
Tool: mcp.Tool{Name: "delete", Annotations: &mcp.ToolAnnotations{Title: "Delete"}},
63+
ScopeAccess: scopes.RequireAll(scopes.DeleteRepo, scopes.Repo),
8264
}
8365

84-
for _, tt := range tests {
85-
t.Run(tt.name, func(t *testing.T) {
86-
var buf strings.Builder
87-
writeToolDoc(&buf, tt.tool)
88-
assert.Contains(t, buf.String(), tt.want)
89-
})
90-
}
66+
var buf strings.Builder
67+
writeToolDoc(&buf, tool)
68+
assert.Contains(t, buf.String(), "**OAuth Challenge Scopes**: `delete_repo`, `repo`")
9169
}
9270

9371
func TestSchemaTypeString(t *testing.T) {

0 commit comments

Comments
 (0)