Skip to content

Commit 0e29cc4

Browse files
authored
[client] Implement MCP OAuth scope selection and step-up authorization (#1324)
1 parent 61399b3 commit 0e29cc4

File tree

3 files changed

+317
-52
lines changed

3 files changed

+317
-52
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ ipython_config.py
8989
# pyenv
9090
# For a library or package, you might want to ignore these files since the code is
9191
# intended to run in multiple environments; otherwise, check them in:
92-
# .python-version
92+
.python-version
9393

9494
# pipenv
9595
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.

src/mcp/client/auth.py

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -204,22 +204,19 @@ def __init__(
204204
)
205205
self._initialized = False
206206

207-
def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response) -> str | None:
207+
def _extract_field_from_www_auth(self, init_response: httpx.Response, field_name: str) -> str | None:
208208
"""
209-
Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
209+
Extract field from WWW-Authenticate header.
210210
211211
Returns:
212-
Resource metadata URL if found in WWW-Authenticate header, None otherwise
212+
Field value if found in WWW-Authenticate header, None otherwise
213213
"""
214-
if not init_response or init_response.status_code != 401:
215-
return None
216-
217214
www_auth_header = init_response.headers.get("WWW-Authenticate")
218215
if not www_auth_header:
219216
return None
220217

221-
# Pattern matches: resource_metadata="url" or resource_metadata=url (unquoted)
222-
pattern = r'resource_metadata=(?:"([^"]+)"|([^\s,]+))'
218+
# Pattern matches: field_name="value" or field_name=value (unquoted)
219+
pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))'
223220
match = re.search(pattern, www_auth_header)
224221

225222
if match:
@@ -228,6 +225,27 @@ def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response
228225

229226
return None
230227

228+
def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response) -> str | None:
229+
"""
230+
Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
231+
232+
Returns:
233+
Resource metadata URL if found in WWW-Authenticate header, None otherwise
234+
"""
235+
if not init_response or init_response.status_code != 401:
236+
return None
237+
238+
return self._extract_field_from_www_auth(init_response, "resource_metadata")
239+
240+
def _extract_scope_from_www_auth(self, init_response: httpx.Response) -> str | None:
241+
"""
242+
Extract scope parameter from WWW-Authenticate header as per RFC6750.
243+
244+
Returns:
245+
Scope string if found in WWW-Authenticate header, None otherwise
246+
"""
247+
return self._extract_field_from_www_auth(init_response, "scope")
248+
231249
async def _discover_protected_resource(self, init_response: httpx.Response) -> httpx.Request:
232250
# RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
233251
url = self._extract_resource_metadata_from_www_auth(init_response)
@@ -248,8 +266,32 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
248266
self.context.protected_resource_metadata = metadata
249267
if metadata.authorization_servers:
250268
self.context.auth_server_url = str(metadata.authorization_servers[0])
269+
251270
except ValidationError:
252271
pass
272+
else:
273+
raise OAuthFlowError(f"Protected Resource Metadata request failed: {response.status_code}")
274+
275+
def _select_scopes(self, init_response: httpx.Response) -> None:
276+
"""Select scopes as outlined in the 'Scope Selection Strategy in the MCP spec."""
277+
# Per MCP spec, scope selection priority order:
278+
# 1. Use scope from WWW-Authenticate header (if provided)
279+
# 2. Use all scopes from PRM scopes_supported (if available)
280+
# 3. Omit scope parameter if neither is available
281+
#
282+
www_authenticate_scope = self._extract_scope_from_www_auth(init_response)
283+
if www_authenticate_scope is not None:
284+
# Priority 1: WWW-Authenticate header scope
285+
self.context.client_metadata.scope = www_authenticate_scope
286+
elif (
287+
self.context.protected_resource_metadata is not None
288+
and self.context.protected_resource_metadata.scopes_supported is not None
289+
):
290+
# Priority 2: PRM scopes_supported
291+
self.context.client_metadata.scope = " ".join(self.context.protected_resource_metadata.scopes_supported)
292+
else:
293+
# Priority 3: Omit scope parameter
294+
self.context.client_metadata.scope = None
253295

254296
def _get_discovery_urls(self) -> list[str]:
255297
"""Generate ordered list of (url, type) tuples for discovery attempts."""
@@ -478,9 +520,6 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non
478520
content = await response.aread()
479521
metadata = OAuthMetadata.model_validate_json(content)
480522
self.context.oauth_metadata = metadata
481-
# Apply default scope if needed
482-
if self.context.client_metadata.scope is None and metadata.scopes_supported is not None:
483-
self.context.client_metadata.scope = " ".join(metadata.scopes_supported)
484523

485524
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
486525
"""HTTPX auth flow integration."""
@@ -514,7 +553,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
514553
discovery_response = yield discovery_request
515554
await self._handle_protected_resource_response(discovery_response)
516555

517-
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
556+
# Step 2: Apply scope selection strategy
557+
self._select_scopes(response)
558+
559+
# Step 3: Discover OAuth metadata (with fallback for legacy servers)
518560
discovery_urls = self._get_discovery_urls()
519561
for url in discovery_urls:
520562
oauth_metadata_request = self._create_oauth_metadata_request(url)
@@ -529,16 +571,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
529571
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
530572
break # Non-4XX error, stop trying
531573

532-
# Step 3: Register client if needed
574+
# Step 4: Register client if needed
533575
registration_request = await self._register_client()
534576
if registration_request:
535577
registration_response = yield registration_request
536578
await self._handle_registration_response(registration_response)
537579

538-
# Step 4: Perform authorization
580+
# Step 5: Perform authorization
539581
auth_code, code_verifier = await self._perform_authorization()
540582

541-
# Step 5: Exchange authorization code for tokens
583+
# Step 6: Exchange authorization code for tokens
542584
token_request = await self._exchange_token(auth_code, code_verifier)
543585
token_response = yield token_request
544586
await self._handle_token_response(token_response)
@@ -549,3 +591,27 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
549591
# Retry with new tokens
550592
self._add_auth_header(request)
551593
yield request
594+
elif response.status_code == 403:
595+
# Step 1: Extract error field from WWW-Authenticate header
596+
error = self._extract_field_from_www_auth(response, "error")
597+
598+
# Step 2: Check if we need to step-up authorization
599+
if error == "insufficient_scope":
600+
try:
601+
# Step 2a: Update the required scopes
602+
self._select_scopes(response)
603+
604+
# Step 2b: Perform (re-)authorization
605+
auth_code, code_verifier = await self._perform_authorization()
606+
607+
# Step 2c: Exchange authorization code for tokens
608+
token_request = await self._exchange_token(auth_code, code_verifier)
609+
token_response = yield token_request
610+
await self._handle_token_response(token_response)
611+
except Exception:
612+
logger.exception("OAuth flow error")
613+
raise
614+
615+
# Retry with new tokens
616+
self._add_auth_header(request)
617+
yield request

0 commit comments

Comments
 (0)