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
2 changed files with 413 additions and 23 deletions

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