Skip to content

Commit 7d13a7a

Browse files
SamMorrowDrumsCopilotCopilot
authored
fix(oauth): advertise only default scopes in protected resource metadata (#3251)
* fix(oauth): advertise only default scopes in metadata Keep the full OAuth scope catalog available for per-tool step-up challenges, but limit protected resource discovery to the lower-risk default grant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Update expectedScopes in oauth_test.go Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 6e00cee commit 7d13a7a

3 files changed

Lines changed: 45 additions & 10 deletions

File tree

docs/streamable-http.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,23 @@ The OAuth protected resource metadata's `resource` attribute will be populated w
7272
],
7373
"scopes_supported": [
7474
"repo",
75-
...
75+
"read:org",
76+
"read:user",
77+
"user:email",
78+
"read:packages",
79+
"write:packages",
80+
"read:project",
81+
"project",
82+
"gist",
83+
"notifications"
7684
],
7785
...
7886
}
7987
```
8088

8189
This allows OAuth clients to discover authentication requirements and endpoint information automatically.
90+
Scopes excluded from this default set, such as `delete_repo`, are requested only
91+
through a per-tool OAuth authorization challenge when needed.
8292

8393
The HTTP server is the OAuth protected resource, not the authorization server. It
8494
therefore serves `/.well-known/oauth-protected-resource` but does not serve

pkg/http/oauth/oauth.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ const (
2020
OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource"
2121
)
2222

23-
// SupportedScopes lists every OAuth scope that an MCP tool may require. HTTP
24-
// protected-resource metadata advertises this full set so clients can step up
25-
// authorization for tools excluded from the default grant.
23+
// SupportedScopes lists every OAuth scope that an MCP tool may require.
2624
var SupportedScopes = scopes.SupportedOAuthScopes()
2725

28-
// DefaultScopes are requested by stdio OAuth unless the operator explicitly
29-
// supplies --oauth-scopes. High-risk scopes such as delete_repo require opt-in.
26+
// DefaultScopes are advertised in protected-resource metadata and requested by
27+
// stdio OAuth unless the operator explicitly supplies --oauth-scopes. Other
28+
// scopes require opt-in through a per-tool authorization challenge.
3029
var DefaultScopes = scopes.DefaultOAuthScopes()
3130

3231
// Config holds the OAuth configuration for the MCP server.
@@ -128,7 +127,7 @@ func (h *AuthHandler) metadataHandler() http.Handler {
128127
Resource: resourceURL,
129128
AuthorizationServers: []string{authorizationServerURL},
130129
ResourceName: "GitHub MCP Server",
131-
ScopesSupported: SupportedScopes,
130+
ScopesSupported: DefaultScopes,
132131
BearerMethodsSupported: []string{"header"},
133132
}
134133

pkg/http/oauth/oauth_test.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,18 @@ func TestHandleProtectedResource(t *testing.T) {
436436
host: "api.example.com",
437437
method: http.MethodGet,
438438
expectedStatusCode: http.StatusOK,
439-
expectedScopes: SupportedScopes,
439+
expectedScopes: []string{
440+
"repo",
441+
"read:org",
442+
"read:user",
443+
"user:email",
444+
"read:packages",
445+
"write:packages",
446+
"read:project",
447+
"project",
448+
"gist",
449+
"notifications",
450+
},
440451
validateResponse: func(t *testing.T, body map[string]any) {
441452
t.Helper()
442453
assert.Equal(t, "GitHub MCP Server", body["resource_name"])
@@ -573,7 +584,12 @@ func TestHandleProtectedResource(t *testing.T) {
573584
if tc.expectedScopes != nil {
574585
scopes, ok := body["scopes_supported"].([]any)
575586
require.True(t, ok)
576-
assert.Len(t, scopes, len(tc.expectedScopes))
587+
actualScopes := make([]string, len(scopes))
588+
for i, scope := range scopes {
589+
actualScopes[i], ok = scope.(string)
590+
require.True(t, ok)
591+
}
592+
assert.Equal(t, tc.expectedScopes, actualScopes)
577593
}
578594
}
579595
})
@@ -671,10 +687,20 @@ func TestSupportedScopes(t *testing.T) {
671687
assert.Equal(t, expectedScopes, SupportedScopes)
672688
}
673689

674-
func TestDefaultScopesRequiresExplicitDeleteRepoOptIn(t *testing.T) {
690+
func TestDefaultScopesRequireExplicitOptIn(t *testing.T) {
675691
assert.Subset(t, SupportedScopes, DefaultScopes)
676692
assert.Contains(t, SupportedScopes, "delete_repo")
677693
assert.NotContains(t, DefaultScopes, "delete_repo")
694+
assert.Contains(t, SupportedScopes, "workflow")
695+
assert.NotContains(t, DefaultScopes, "workflow")
696+
assert.Contains(t, SupportedScopes, "codespace")
697+
assert.NotContains(t, DefaultScopes, "codespace")
698+
assert.Contains(t, SupportedScopes, "admin:org")
699+
assert.NotContains(t, DefaultScopes, "admin:org")
700+
assert.Contains(t, SupportedScopes, "read:enterprise")
701+
assert.NotContains(t, DefaultScopes, "read:enterprise")
702+
assert.Contains(t, SupportedScopes, "admin:enterprise")
703+
assert.NotContains(t, DefaultScopes, "admin:enterprise")
678704
assert.Contains(t, DefaultScopes, "repo")
679705
}
680706

0 commit comments

Comments
 (0)