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

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 {