Union previously requested scopes on step-up re-authorization (SEP-2350) (#2931)

This commit is contained in:
Marcelo Trylesinski 2026-06-20 17:45:04 +01:00 • committed by GitHub
commit 1331131650
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 155 additions and 8 deletions

View file

@ -35,6 +35,7 @@ from mcp.client.auth.utils import (
handle_token_response_scopes,
is_valid_client_metadata_url,
should_use_client_metadata_url,
union_scopes,
validate_authorization_response_iss,
validate_metadata_issuer,
)
@ -634,13 +635,19 @@ class OAuthClientProvider(httpx.Auth):
# Step 2: Check if we need to step-up authorization
if error == "insufficient_scope": # pragma: no branch
try:
# Step 2a: Update the required scopes
self.context.client_metadata.scope = get_client_metadata_scopes(
# Step 2a: Union previously requested scopes with the newly challenged
# scopes (SEP-2350) so escalating one operation keeps the others' grants.
# Fold in the stored token's scope too: on a restart the token is reloaded
# but client_metadata.scope is not, so it would otherwise be the only basis.
challenged_scope = get_client_metadata_scopes(
extract_scope_from_www_auth(response),
self.context.protected_resource_metadata,
self.context.oauth_metadata,
self.context.client_metadata.grant_types,
)
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
# Step 2b: Perform (re-)authorization and token exchange
token_response = yield await self._perform_authorization()

View file

@ -131,6 +131,28 @@ def get_client_metadata_scopes(
return selected_scope
def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | None:
"""Merge two space-delimited scope strings, preserving order and dropping duplicates.
SEP-2350: on step-up re-authorization the client requests the union of previously requested
scopes and the newly challenged scopes, so escalating one operation does not drop the
permissions granted for another. Previously requested scopes come first; new scopes are
appended in order.
"""
if not previous_scope:
return new_scope
if not new_scope:
return previous_scope
merged = previous_scope.split()
seen = set(merged)
for scope in new_scope.split():
if scope not in seen:
merged.append(scope)
seen.add(scope)
return " ".join(merged)
def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]:
"""Generate an ordered list of URLs for authorization server metadata discovery.