Skip to content

Commit 2854f32

Browse files
authored
Merge branch 'main' into enum_updates
2 parents 208c141 + dcc68ce commit 2854f32

File tree

23 files changed

+1063
-245
lines changed

23 files changed

+1063
-245
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.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,7 +1840,7 @@ import asyncio
18401840

18411841
from mcp.client.session import ClientSession
18421842
from mcp.client.stdio import StdioServerParameters, stdio_client
1843-
from mcp.types import Resource
1843+
from mcp.types import PaginatedRequestParams, Resource
18441844

18451845

18461846
async def list_all_resources() -> None:
@@ -1857,7 +1857,7 @@ async def list_all_resources() -> None:
18571857

18581858
while True:
18591859
# Fetch a page of resources
1860-
result = await session.list_resources(cursor=cursor)
1860+
result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor))
18611861
all_resources.extend(result.resources)
18621862

18631863
print(f"Fetched {len(result.resources)} resources")

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,6 @@ class ResourceServerSettings(BaseSettings):
4545
# RFC 8707 resource validation
4646
oauth_strict: bool = False
4747

48-
# TODO(Marcelo): Is this even needed? I didn't have time to check.
49-
def __init__(self, **data: Any):
50-
"""Initialize settings with values from environment variables."""
51-
super().__init__(**data)
52-
5348

5449
def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
5550
"""
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."""
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"]

examples/snippets/clients/pagination_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from mcp.client.session import ClientSession
88
from mcp.client.stdio import StdioServerParameters, stdio_client
9-
from mcp.types import Resource
9+
from mcp.types import PaginatedRequestParams, Resource
1010

1111

1212
async def list_all_resources() -> None:
@@ -23,7 +23,7 @@ async def list_all_resources() -> None:
2323

2424
while True:
2525
# Fetch a page of resources
26-
result = await session.list_resources(cursor=cursor)
26+
result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor))
2727
all_resources.extend(result.resources)
2828

2929
print(f"Fetched {len(result.resources)} resources")

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)