Skip to content

Commit 1367184

Browse files
committed
feat: implement refresh token guidance sep-2207
1 parent d6d3ad9 commit 1367184

File tree

3 files changed

+390
-15
lines changed

3 files changed

+390
-15
lines changed

src/mcp/client/auth/oauth2.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
346346
if self.context.client_metadata.scope: # pragma: no branch
347347
auth_params["scope"] = self.context.client_metadata.scope
348348

349+
# OIDC requires prompt=consent when offline_access is requested
350+
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
351+
if "offline_access" in self.context.client_metadata.scope.split():
352+
auth_params["prompt"] = "consent"
353+
349354
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
350355
await self.context.redirect_handler(authorization_url)
351356

@@ -580,6 +585,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
580585
extract_scope_from_www_auth(response),
581586
self.context.protected_resource_metadata,
582587
self.context.oauth_metadata,
588+
self.context.client_metadata.grant_types,
583589
)
584590

585591
# Step 4: Register client or use URL-based client ID (CIMD)
@@ -626,7 +632,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
626632
try:
627633
# Step 2a: Update the required scopes
628634
self.context.client_metadata.scope = get_client_metadata_scopes(
629-
extract_scope_from_www_auth(response), self.context.protected_resource_metadata
635+
extract_scope_from_www_auth(response),
636+
self.context.protected_resource_metadata,
637+
self.context.oauth_metadata,
638+
self.context.client_metadata.grant_types,
630639
)
631640

632641
# Step 2b: Perform (re-)authorization and token exchange

src/mcp/client/auth/utils.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,24 +99,36 @@ def get_client_metadata_scopes(
9999
www_authenticate_scope: str | None,
100100
protected_resource_metadata: ProtectedResourceMetadata | None,
101101
authorization_server_metadata: OAuthMetadata | None = None,
102+
client_grant_types: list[str] | None = None,
102103
) -> str | None:
103-
"""Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec."""
104-
# Per MCP spec, scope selection priority order:
105-
# 1. Use scope from WWW-Authenticate header (if provided)
106-
# 2. Use all scopes from PRM scopes_supported (if available)
107-
# 3. Omit scope parameter if neither is available
108-
104+
"""Select effective scopes and augment for refresh token support."""
105+
selected_scope: str | None = None
106+
107+
# MCP spec scope selection priority:
108+
# 1. WWW-Authenticate header scope
109+
# 2. PRM scopes_supported
110+
# 3. AS scopes_supported (SDK fallback)
111+
# 4. Omit scope parameter
109112
if www_authenticate_scope is not None:
110-
# Priority 1: WWW-Authenticate header scope
111-
return www_authenticate_scope
113+
selected_scope = www_authenticate_scope
112114
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
113-
# Priority 2: PRM scopes_supported
114-
return " ".join(protected_resource_metadata.scopes_supported)
115+
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
115116
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
116-
return " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover
117-
else:
118-
# Priority 3: Omit scope parameter
119-
return None
117+
selected_scope = " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover
118+
119+
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
120+
if (
121+
selected_scope is not None
122+
and authorization_server_metadata is not None
123+
and authorization_server_metadata.scopes_supported is not None
124+
and "offline_access" in authorization_server_metadata.scopes_supported
125+
and client_grant_types is not None
126+
and "refresh_token" in client_grant_types
127+
and "offline_access" not in selected_scope.split()
128+
):
129+
selected_scope = f"{selected_scope} offline_access"
130+
131+
return selected_scope
120132

121133

122134
def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]:

0 commit comments

Comments
 (0)