mcp: Add client-side scope accumulation (#944)

## Description

This PR implements
https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350

Fixes #943

---------

Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This commit is contained in:
Guglielmo Colombo 2026-05-18 10:26:02 +02:00 • committed by GitHub
commit bf69179066
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 413 additions and 23 deletions

View file

@ -15,6 +15,7 @@ import (
"slices"
"strings"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
@ -128,6 +129,9 @@ type AuthorizationCodeHandler struct {
// tokenSource is the token source to use for authorization.
tokenSource oauth2.TokenSource
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
}
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
@ -187,7 +191,10 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
if config.Client == nil {
config.Client = http.DefaultClient
}
return &AuthorizationCodeHandler{config: config}, nil
return &AuthorizationCodeHandler{
config: config,
grantedScopes: make(map[string][]string),
}, nil
}
func isNonRootHTTPSURL(u string) bool {
@ -275,19 +282,24 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
return err
}
scps := scopesFromChallenges(wwwChallenges)
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
scps = prm.ScopesSupported
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
}
// SEP-2207: when the client desires refresh tokens and the Authorization
// Server advertises offline_access support, add it to the requested scopes.
if h.config.RequestRefreshToken &&
slices.Contains(asm.ScopesSupported, "offline_access") &&
!slices.Contains(scps, "offline_access") {
scps = append(scps, "offline_access")
!slices.Contains(requestedScopes, "offline_access") {
requestedScopes = append(requestedScopes, "offline_access")
}
// Accumulate scopes: union previously granted scopes with the newly
// challenged scopes so that step-up authorization does not lose
// permissions granted in earlier rounds (SEP-2350).
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
cfg := &oauth2.Config{
ClientID: resolvedClientConfig.clientID,
ClientSecret: resolvedClientConfig.clientSecret,
@ -298,7 +310,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
AuthStyle: resolvedClientConfig.authStyle,
},
RedirectURL: h.config.RedirectURL,
Scopes: scps,
Scopes: requestedScopes,
}
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
@ -307,7 +319,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
return err
}
return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
if err != nil {
return err
}
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
}
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
@ -558,3 +575,20 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
h.tokenSource = cfg.TokenSource(clientCtx, token)
return nil
}
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
if h.tokenSource == nil {
return nil
}
tok, err := h.tokenSource.Token()
if err != nil {
return err
}
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
h.grantedScopes[issuer] = requestedScopes
} else {
h.grantedScopes[issuer] = tokenScopes
}
return nil
}

View file

@ -16,6 +16,7 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
@ -113,6 +114,131 @@ func TestAuthorize(t *testing.T) {
}
}
func TestAuthorize_ScopeAccumulation(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test_client_id": {
Secret: "test_client_secret",
RedirectURIs: []string{"http://localhost:12345/callback"},
},
},
},
TokenScopeFunc: func(requestedScope string) string {
// Simulate a server that never grants "write".
var granted []string
for _, s := range strings.Fields(requestedScope) {
if s != "write" {
granted = append(granted, s)
}
}
return strings.Join(granted, " ")
},
})
authServer.Start(t)
resourceMux := http.NewServeMux()
resourceServer := httptest.NewServer(resourceMux)
t.Cleanup(resourceServer.Close)
resourceURL := resourceServer.URL + "/resource"
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
var capturedAuthURLs []string
noRedirectClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost:12345/callback",
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "test_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "test_client_secret",
},
},
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
capturedAuthURLs = append(capturedAuthURLs, args.URL)
resp, err := noRedirectClient.Get(args.URL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
loc, err := resp.Location()
if err != nil {
return nil, err
}
return &AuthorizationResult{
Code: loc.Query().Get("code"),
State: loc.Query().Get("state"),
}, nil
},
})
if err != nil {
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
}
// First authorization: 401 with scope="read write".
// The token response will only grant "read" (TokenScopeFunc strips "write").
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp.Header.Set("WWW-Authenticate",
fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
if err := handler.Authorize(context.Background(), req, resp); err != nil {
t.Fatalf("First Authorize failed: %v", err)
}
// Verify first auth URL requested "read" and "write".
firstURL, err := url.Parse(capturedAuthURLs[0])
if err != nil {
t.Fatalf("Failed to parse first auth URL: %v", err)
}
firstScopes := strings.Fields(firstURL.Query().Get("scope"))
if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff)
}
// Verify only "read" was granted (the token omitted "write").
issuer := authServer.URL()
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
// Second authorization: 403 insufficient_scope with scope="admin".
// Accumulated scopes should be "read" (previously granted) + "admin" (new).
req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil)
resp2 := &http.Response{
StatusCode: http.StatusForbidden,
Header: make(http.Header),
Body: http.NoBody,
}
resp2.Header.Set("WWW-Authenticate",
fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
if err := handler.Authorize(context.Background(), req2, resp2); err != nil {
t.Fatalf("Second Authorize failed: %v", err)
}
// Verify second auth URL accumulated "read" (granted) + "admin" (challenged),
// but NOT "write" (requested but never granted).
secondURL, err := url.Parse(capturedAuthURLs[1])
if err != nil {
t.Fatalf("Failed to parse second auth URL: %v", err)
}
secondScopes := strings.Fields(secondURL.Query().Get("scope"))
if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff)
}
}
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
resp := &http.Response{

View file

@ -14,6 +14,7 @@ import (
"strings"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
@ -47,6 +48,9 @@ type ClientCredentialsHandlerConfig struct {
type ClientCredentialsHandler struct {
config *ClientCredentialsHandlerConfig
tokenSource oauth2.TokenSource
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
}
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
@ -67,7 +71,10 @@ func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*Clien
if config.Credentials.ClientSecretAuth == nil {
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
}
return &ClientCredentialsHandler{config: config}, nil
return &ClientCredentialsHandler{
config: config,
grantedScopes: make(map[string][]string),
}, nil
}
// TokenSource returns the token source for outgoing requests.
@ -121,30 +128,47 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
}
}
// Determine scopes: use PRM's scopes_supported if available.
scopes := scopesFromChallenges(wwwChallenges)
if len(scopes) == 0 && len(prm.ScopesSupported) > 0 {
scopes = prm.ScopesSupported
// Determine requestedScopes: use PRM's scopes_supported if available.
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
}
// Accumulate scopes: union previously granted scopes with the newly
// challenged scopes so that step-up authorization does not lose
// permissions granted in earlier rounds (SEP-2350).
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
// Step 3: Exchange client credentials for an access token.
creds := h.config.Credentials
cfg := &clientcredentials.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecretAuth.ClientSecret,
TokenURL: asm.TokenEndpoint,
Scopes: scopes,
Scopes: requestedScopes,
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
h.tokenSource = cfg.TokenSource(ctxWithClient)
// Eagerly fetch a token to surface errors immediately.
if _, err := h.tokenSource.Token(); err != nil {
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
}
func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
if h.tokenSource == nil {
return nil
}
tok, err := h.tokenSource.Token()
if err != nil {
h.tokenSource = nil
return fmt.Errorf("client credentials token request failed: %w", err)
}
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
h.grantedScopes[issuer] = requestedScopes
} else {
h.grantedScopes[issuer] = tokenScopes
}
return nil
}

View file

@ -10,6 +10,8 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
@ -255,6 +257,86 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) {
})
}
func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test-client": {Secret: "test-secret"},
},
},
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
Enabled: true,
},
TokenScopeFunc: func(requestedScope string) string {
// Simulate a server that never grants "write".
var granted []string
for _, s := range strings.Fields(requestedScope) {
if s != "write" {
granted = append(granted, s)
}
}
return strings.Join(granted, " ")
},
})
authServer.Start(t)
resourceMux := http.NewServeMux()
resourceServer := httptest.NewServer(resourceMux)
t.Cleanup(resourceServer.Close)
resourceURL := resourceServer.URL + "/resource"
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
// First authorization: 401 with scope="read write".
// The token response will only grant "read" (TokenScopeFunc strips "write").
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`)
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("First Authorize failed: %v", err)
}
// Verify only "read" was granted (the token omitted "write").
issuer := authServer.URL()
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
// Second authorization: 401 with scope="admin" (simulating step-up).
// Accumulated scopes should be "read" (previously granted) + "admin" (new),
// but NOT "write" (requested but never granted).
resp2 := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`)
req2 := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req2, resp2); err != nil {
t.Fatalf("Second Authorize failed: %v", err)
}
// Verify accumulated scopes: "read" (granted) + "admin" (new), no "write".
if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
}
func TestSelectTokenAuthMethod(t *testing.T) {
tests := []struct {
name string

View file

@ -0,0 +1,36 @@
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package authutil
import (
"maps"
"slices"
"strings"
"golang.org/x/oauth2"
)
// UnionScopes returns the union of the existing and challenged scope sets.
// It is used during step-up authorization to accumulate scopes across
// authorization rounds (SEP-2350).
func UnionScopes(existing, challenged []string) []string {
combined := make(map[string]struct{})
for _, s := range existing {
combined[s] = struct{}{}
}
for _, s := range challenged {
combined[s] = struct{}{}
}
return slices.Collect(maps.Keys(combined))
}
// ScopesFromToken extracts the granted scopes from an OAuth2 token response.
// Per RFC 6749 §5.1, the scope parameter is optional; returns nil if absent.
func ScopesFromToken(token *oauth2.Token) []string {
scope, ok := token.Extra("scope").(string)
if !ok {
return nil
}
return strings.Fields(scope)
}

View file

@ -0,0 +1,72 @@
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package authutil
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestUnionScopes(t *testing.T) {
tests := []struct {
name string
existing []string
challenged []string
want []string
}{
{
name: "both empty",
existing: nil,
challenged: nil,
want: nil,
},
{
name: "existing only",
existing: []string{"read"},
challenged: nil,
want: []string{"read"},
},
{
name: "challenged only",
existing: nil,
challenged: []string{"write"},
want: []string{"write"},
},
{
name: "disjoint scopes",
existing: []string{"read"},
challenged: []string{"write"},
want: []string{"read", "write"},
},
{
name: "overlapping scopes",
existing: []string{"read", "write"},
challenged: []string{"write", "admin"},
want: []string{"read", "write", "admin"},
},
{
name: "identical scopes",
existing: []string{"read", "write"},
challenged: []string{"read", "write"},
want: []string{"read", "write"},
},
{
name: "mixed scopes",
existing: []string{"b", "a"},
challenged: []string{"c", "a"},
want: []string{"a", "b", "c"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UnionScopes(tt.existing, tt.challenged)
if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("UnionScopes() mismatch (-want +got):\n%s", diff)
}
})
}
}

View file

@ -85,6 +85,9 @@ type Config struct {
// ScopesSupported is an optional list of scopes to advertise in the
// authorization server metadata.
ScopesSupported []string
// TokenScopeFunc, if set, is called with the scope from the authorization
// request and returns the scope string to include in the token response.
TokenScopeFunc func(requestedScope string) string
}
// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing.
@ -98,6 +101,7 @@ type FakeAuthorizationServer struct {
type codeInfo struct {
CodeChallenge string
Scope string
}
// NewFakeAuthorizationServer creates a new FakeAuthorizationServer.
@ -259,6 +263,7 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http
code := rand.Text()
s.codes[code] = codeInfo{
CodeChallenge: codeChallenge,
Scope: r.URL.Query().Get("scope"),
}
state := r.URL.Query().Get("state")
@ -317,12 +322,18 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
resp := map[string]any{
"access_token": "test_access_token",
"token_type": "Bearer",
"expires_in": 3600,
})
}
if s.config.TokenScopeFunc != nil {
if scope := s.config.TokenScopeFunc(codeInfo.Scope); scope != "" {
resp["scope"] = scope
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) {
@ -355,13 +366,18 @@ func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWr
http.Error(w, "client_credentials grant not supported", http.StatusBadRequest)
return
}
// Client was already authenticated in handleToken.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
resp := map[string]any{
"access_token": "test_access_token",
"token_type": "Bearer",
"expires_in": 3600,
})
}
if s.config.TokenScopeFunc != nil {
if scope := s.config.TokenScopeFunc(r.Form.Get("scope")); scope != "" {
resp["scope"] = scope
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error {