Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
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")
Expand All @@ -341,11 +341,16 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:

# 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)

Expand Down Expand Up @@ -580,6 +585,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
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)
Expand Down Expand Up @@ -626,7 +632,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
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
Expand Down
40 changes: 26 additions & 14 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading