SEP-2207: Refresh token guidance (#2039)

This commit is contained in:
Wils Dawson 2026-04-14 03:48:07 -07:00 • committed by GitHub
commit 437d15aa71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 392 additions and 17 deletions

View file

@ -320,7 +320,7 @@ class OAuthClientProvider(httpx.Auth):
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover
if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint:
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) # pragma: no cover
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint)
else:
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
auth_endpoint = urljoin(auth_base_url, "/authorize")
@ -343,11 +343,16 @@ class OAuthClientProvider(httpx.Auth):
# Only include resource param if conditions are met
if self.context.should_include_resource_param(self.context.protocol_version):
auth_params["resource"] = self.context.get_resource_url() # RFC 8707 # pragma: no cover
auth_params["resource"] = self.context.get_resource_url() # RFC 8707
if self.context.client_metadata.scope: # pragma: no branch
auth_params["scope"] = self.context.client_metadata.scope
# OIDC requires prompt=consent when offline_access is requested
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if "offline_access" in self.context.client_metadata.scope.split():
auth_params["prompt"] = "consent"
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
await self.context.redirect_handler(authorization_url)
@ -576,6 +581,7 @@ class OAuthClientProvider(httpx.Auth):
extract_scope_from_www_auth(response),
self.context.protected_resource_metadata,
self.context.oauth_metadata,
self.context.client_metadata.grant_types,
)
# Step 4: Register client or use URL-based client ID (CIMD)
@ -622,7 +628,10 @@ class OAuthClientProvider(httpx.Auth):
try:
# Step 2a: Update the required scopes
self.context.client_metadata.scope = get_client_metadata_scopes(
extract_scope_from_www_auth(response), self.context.protected_resource_metadata
extract_scope_from_www_auth(response),
self.context.protected_resource_metadata,
self.context.oauth_metadata,
self.context.client_metadata.grant_types,
)
# Step 2b: Perform (re-)authorization and token exchange

View file

@ -99,24 +99,36 @@ def get_client_metadata_scopes(
www_authenticate_scope: str | None,
protected_resource_metadata: ProtectedResourceMetadata | None,
authorization_server_metadata: OAuthMetadata | None = None,
client_grant_types: list[str] | None = None,
) -> str | None:
"""Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec."""
# Per MCP spec, scope selection priority order:
# 1. Use scope from WWW-Authenticate header (if provided)
# 2. Use all scopes from PRM scopes_supported (if available)
# 3. Omit scope parameter if neither is available
"""Select effective scopes and augment for refresh token support."""
selected_scope: str | None = None
# MCP spec scope selection priority:
# 1. WWW-Authenticate header scope
# 2. PRM scopes_supported
# 3. AS scopes_supported (SDK fallback)
# 4. Omit scope parameter
if www_authenticate_scope is not None:
# Priority 1: WWW-Authenticate header scope
return www_authenticate_scope
selected_scope = www_authenticate_scope
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
# Priority 2: PRM scopes_supported
return " ".join(protected_resource_metadata.scopes_supported)
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
return " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover
else:
# Priority 3: Omit scope parameter
return None
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
if (
selected_scope is not None
and authorization_server_metadata is not None
and authorization_server_metadata.scopes_supported is not None
and "offline_access" in authorization_server_metadata.scopes_supported
and client_grant_types is not None
and "refresh_token" in client_grant_types
and "offline_access" not in selected_scope.split()
):
selected_scope = f"{selected_scope} offline_access"
return selected_scope
def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: