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

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