Skip to content

Commit f9589d6

Browse files
authored
Merge branch 'main' into main
2 parents 6c24b63 + 7a933fe commit f9589d6

File tree

9 files changed

+439
-50
lines changed

9 files changed

+439
-50
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.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Example of structured output with low-level MCP server."""

examples/servers/structured_output_lowlevel.py renamed to examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py

File renamed without changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "mcp-structured-output-lowlevel"
3+
version = "0.1.0"
4+
description = "Example of structured output with low-level MCP server"
5+
requires-python = ">=3.10"
6+
dependencies = ["mcp"]

src/mcp/client/auth.py

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -303,22 +303,19 @@ def __init__(
303303
)
304304
self._initialized = False
305305

306-
def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response) -> str | None:
306+
def _extract_field_from_www_auth(self, init_response: httpx.Response, field_name: str) -> str | None:
307307
"""
308-
Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
308+
Extract field from WWW-Authenticate header.
309309
310310
Returns:
311-
Resource metadata URL if found in WWW-Authenticate header, None otherwise
311+
Field value if found in WWW-Authenticate header, None otherwise
312312
"""
313-
if not init_response or init_response.status_code != 401:
314-
return None
315-
316313
www_auth_header = init_response.headers.get("WWW-Authenticate")
317314
if not www_auth_header:
318315
return None
319316

320-
# Pattern matches: resource_metadata="url" or resource_metadata=url (unquoted)
321-
pattern = r'resource_metadata=(?:"([^"]+)"|([^\s,]+))'
317+
# Pattern matches: field_name="value" or field_name=value (unquoted)
318+
pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))'
322319
match = re.search(pattern, www_auth_header)
323320

324321
if match:
@@ -327,6 +324,27 @@ def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response
327324

328325
return None
329326

327+
def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response) -> str | None:
328+
"""
329+
Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
330+
331+
Returns:
332+
Resource metadata URL if found in WWW-Authenticate header, None otherwise
333+
"""
334+
if not init_response or init_response.status_code != 401:
335+
return None
336+
337+
return self._extract_field_from_www_auth(init_response, "resource_metadata")
338+
339+
def _extract_scope_from_www_auth(self, init_response: httpx.Response) -> str | None:
340+
"""
341+
Extract scope parameter from WWW-Authenticate header as per RFC6750.
342+
343+
Returns:
344+
Scope string if found in WWW-Authenticate header, None otherwise
345+
"""
346+
return self._extract_field_from_www_auth(init_response, "scope")
347+
330348
async def _discover_protected_resource(self, init_response: httpx.Response) -> httpx.Request:
331349
# RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
332350
url = self._extract_resource_metadata_from_www_auth(init_response)
@@ -347,8 +365,32 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
347365
self.context.protected_resource_metadata = metadata
348366
if metadata.authorization_servers:
349367
self.context.auth_server_url = str(metadata.authorization_servers[0])
368+
350369
except ValidationError:
351370
pass
371+
else:
372+
raise OAuthFlowError(f"Protected Resource Metadata request failed: {response.status_code}")
373+
374+
def _select_scopes(self, init_response: httpx.Response) -> None:
375+
"""Select scopes as outlined in the 'Scope Selection Strategy in the MCP spec."""
376+
# Per MCP spec, scope selection priority order:
377+
# 1. Use scope from WWW-Authenticate header (if provided)
378+
# 2. Use all scopes from PRM scopes_supported (if available)
379+
# 3. Omit scope parameter if neither is available
380+
#
381+
www_authenticate_scope = self._extract_scope_from_www_auth(init_response)
382+
if www_authenticate_scope is not None:
383+
# Priority 1: WWW-Authenticate header scope
384+
self.context.client_metadata.scope = www_authenticate_scope
385+
elif (
386+
self.context.protected_resource_metadata is not None
387+
and self.context.protected_resource_metadata.scopes_supported is not None
388+
):
389+
# Priority 2: PRM scopes_supported
390+
self.context.client_metadata.scope = " ".join(self.context.protected_resource_metadata.scopes_supported)
391+
else:
392+
# Priority 3: Omit scope parameter
393+
self.context.client_metadata.scope = None
352394

353395
# Discovery and registration helpers provided by BaseOAuthProvider
354396

@@ -508,6 +550,17 @@ def _add_auth_header(self, request: httpx.Request) -> None:
508550
if self.context.current_tokens and self.context.current_tokens.access_token:
509551
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
510552

553+
#<<<<<<< main
554+
#=======
555+
def _create_oauth_metadata_request(self, url: str) -> httpx.Request:
556+
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
557+
558+
async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None:
559+
content = await response.aread()
560+
metadata = OAuthMetadata.model_validate_json(content)
561+
self.context.oauth_metadata = metadata
562+
563+
#>>>>>>> main
511564
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
512565
"""HTTPX auth flow integration."""
513566
async with self.context.lock:
@@ -540,8 +593,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
540593
discovery_response = yield discovery_request
541594
await self._handle_protected_resource_response(discovery_response)
542595

596+
#<<<<<<< main
543597
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
544598
discovery_urls = self._get_discovery_urls(self.context.auth_server_url or self.context.server_url)
599+
#=======
600+
# Step 2: Apply scope selection strategy
601+
self._select_scopes(response)
602+
603+
# Step 3: Discover OAuth metadata (with fallback for legacy servers)
604+
discovery_urls = self._get_discovery_urls()
605+
#>>>>>>> main
545606
for url in discovery_urls:
546607
oauth_metadata_request = self._create_oauth_metadata_request(url)
547608
oauth_metadata_response = yield oauth_metadata_request
@@ -556,17 +617,22 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
556617
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
557618
break # Non-4XX error, stop trying
558619

620+
#<<<<<<< main
559621
# Step 3: Register client if needed
560622
registration_request = self._create_registration_request(self._metadata)
623+
#=======
624+
# Step 4: Register client if needed
625+
registration_request = await self._register_client()
626+
#>>>>>>> main
561627
if registration_request:
562628
registration_response = yield registration_request
563629
await self._handle_registration_response(registration_response)
564630
self.context.client_info = self._client_info
565631

566-
# Step 4: Perform authorization
632+
# Step 5: Perform authorization
567633
auth_code, code_verifier = await self._perform_authorization()
568634

569-
# Step 5: Exchange authorization code for tokens
635+
# Step 6: Exchange authorization code for tokens
570636
token_request = await self._exchange_token(auth_code, code_verifier)
571637
token_response = yield token_request
572638
await self._handle_token_response(token_response)
@@ -577,6 +643,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
577643
# Retry with new tokens
578644
self._add_auth_header(request)
579645
yield request
646+
#<<<<<<< main
580647

581648

582649
class ClientCredentialsProvider(BaseOAuthProvider):
@@ -852,3 +919,29 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
852919
response = yield request
853920
if response.status_code == 401:
854921
self._current_tokens = None
922+
#=======
923+
elif response.status_code == 403:
924+
# Step 1: Extract error field from WWW-Authenticate header
925+
error = self._extract_field_from_www_auth(response, "error")
926+
927+
# Step 2: Check if we need to step-up authorization
928+
if error == "insufficient_scope":
929+
try:
930+
# Step 2a: Update the required scopes
931+
self._select_scopes(response)
932+
933+
# Step 2b: Perform (re-)authorization
934+
auth_code, code_verifier = await self._perform_authorization()
935+
936+
# Step 2c: Exchange authorization code for tokens
937+
token_request = await self._exchange_token(auth_code, code_verifier)
938+
token_response = yield token_request
939+
await self._handle_token_response(token_response)
940+
except Exception:
941+
logger.exception("OAuth flow error")
942+
raise
943+
944+
# Retry with new tokens
945+
self._add_auth_header(request)
946+
yield request
947+
#>>>>>>> main

src/mcp/server/lowlevel/server.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,13 +642,21 @@ async def _handle_message(
642642
raise_exceptions: bool = False,
643643
):
644644
with warnings.catch_warnings(record=True) as w:
645-
# TODO(Marcelo): We should be checking if message is Exception here.
646-
match message: # type: ignore[reportMatchNotExhaustive]
645+
match message:
647646
case RequestResponder(request=types.ClientRequest(root=req)) as responder:
648647
with responder:
649648
await self._handle_request(message, req, session, lifespan_context, raise_exceptions)
650649
case types.ClientNotification(root=notify):
651650
await self._handle_notification(notify)
651+
case Exception():
652+
logger.error(f"Received exception from stream: {message}")
653+
await session.send_log_message(
654+
level="error",
655+
data="Internal Server Error",
656+
logger="mcp.server.exception_handler",
657+
)
658+
if raise_exceptions:
659+
raise message
652660

653661
for warning in w:
654662
logger.info("Warning: %s: %s", warning.category.__name__, warning.message)

0 commit comments

Comments
 (0)