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

@ -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)
}
})
}
}