Skip to content

Commit c3c6725

Browse files
Merge branch 'main' into feat/client-credentials
2 parents ac75345 + 35777b9 commit c3c6725

File tree

8 files changed

+331
-208
lines changed

8 files changed

+331
-208
lines changed

.github/CODEOWNERS

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# CODEOWNERS for MCP Python SDK
2+
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
3+
4+
# Default maintainers for everything
5+
* @modelcontextprotocol/python-sdk
6+
7+
# Auth-related code requires additional review from auth team
8+
/src/mcp/client/auth.py @modelcontextprotocol/python-sdk-auth
9+
/src/mcp/server/auth/ @modelcontextprotocol/python-sdk-auth
10+
/src/mcp/server/transport_security.py @modelcontextprotocol/python-sdk-auth
11+
/src/mcp/shared/auth*.py @modelcontextprotocol/python-sdk-auth
12+
13+
# Auth-related tests
14+
/tests/client/test_auth.py @modelcontextprotocol/python-sdk-auth
15+
/tests/server/auth/ @modelcontextprotocol/python-sdk-auth
16+
/tests/server/test_*security.py @modelcontextprotocol/python-sdk-auth
17+
/tests/server/fastmcp/auth/ @modelcontextprotocol/python-sdk-auth
18+
/tests/shared/test_auth*.py @modelcontextprotocol/python-sdk-auth
19+
20+
# Auth-related examples
21+
/examples/clients/simple-auth-client/ @modelcontextprotocol/python-sdk-auth
22+
/examples/snippets/clients/oauth_client.py @modelcontextprotocol/python-sdk-auth
23+
/examples/snippets/servers/oauth_server.py @modelcontextprotocol/python-sdk-auth

src/mcp/client/auth.py

Lines changed: 46 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -251,72 +251,32 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
251251
except ValidationError:
252252
pass
253253

254-
def _build_well_known_path(self, pathname: str) -> str:
255-
"""Construct well-known path for OAuth metadata discovery."""
256-
well_known_path = f"/.well-known/oauth-authorization-server{pathname}"
257-
if pathname.endswith("/"):
258-
# Strip trailing slash from pathname to avoid double slashes
259-
well_known_path = well_known_path[:-1]
260-
return well_known_path
261-
262-
def _should_attempt_fallback(self, response_status: int, pathname: str) -> bool:
263-
"""Determine if fallback to root discovery should be attempted."""
264-
return response_status == 404 and pathname != "/"
265-
266-
async def _try_metadata_discovery(self, url: str) -> httpx.Request:
267-
"""Build metadata discovery request for a specific URL."""
268-
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
269-
270-
async def _discover_oauth_metadata(self) -> httpx.Request:
271-
"""Build OAuth metadata discovery request with fallback support."""
272-
if self.context.auth_server_url:
273-
auth_server_url = self.context.auth_server_url
274-
else:
275-
auth_server_url = self.context.server_url
276-
277-
# Per RFC 8414, try path-aware discovery first
254+
def _get_discovery_urls(self) -> list[str]:
255+
"""Generate ordered list of (url, type) tuples for discovery attempts."""
256+
urls: list[str] = []
257+
auth_server_url = self.context.auth_server_url or self.context.server_url
278258
parsed = urlparse(auth_server_url)
279-
well_known_path = self._build_well_known_path(parsed.path)
280259
base_url = f"{parsed.scheme}://{parsed.netloc}"
281-
url = urljoin(base_url, well_known_path)
282260

283-
# Store fallback info for use in response handler
284-
self.context.discovery_base_url = base_url
285-
self.context.discovery_pathname = parsed.path
261+
# RFC 8414: Path-aware OAuth discovery
262+
if parsed.path and parsed.path != "/":
263+
oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}"
264+
urls.append(urljoin(base_url, oauth_path))
286265

287-
return await self._try_metadata_discovery(url)
266+
# OAuth root fallback
267+
urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server"))
288268

289-
async def _discover_oauth_metadata_fallback(self) -> httpx.Request:
290-
"""Build fallback OAuth metadata discovery request for legacy servers."""
291-
base_url = getattr(self.context, "discovery_base_url", "")
292-
if not base_url:
293-
raise OAuthFlowError("No base URL available for fallback discovery")
294-
295-
# Fallback to root discovery for legacy servers
296-
url = urljoin(base_url, "/.well-known/oauth-authorization-server")
297-
return await self._try_metadata_discovery(url)
298-
299-
async def _handle_oauth_metadata_response(self, response: httpx.Response, is_fallback: bool = False) -> bool:
300-
"""Handle OAuth metadata response. Returns True if handled successfully."""
301-
if response.status_code == 200:
302-
try:
303-
content = await response.aread()
304-
metadata = OAuthMetadata.model_validate_json(content)
305-
self.context.oauth_metadata = metadata
306-
# Apply default scope if none specified
307-
if self.context.client_metadata.scope is None and metadata.scopes_supported is not None:
308-
self.context.client_metadata.scope = " ".join(metadata.scopes_supported)
309-
return True
310-
except ValidationError:
311-
pass
269+
# RFC 8414 section 5: Path-aware OIDC discovery
270+
# See https://www.rfc-editor.org/rfc/rfc8414.html#section-5
271+
if parsed.path and parsed.path != "/":
272+
oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
273+
urls.append(urljoin(base_url, oidc_path))
312274

313-
# Check if we should attempt fallback (404 on path-aware discovery)
314-
if not is_fallback and self._should_attempt_fallback(
315-
response.status_code, getattr(self.context, "discovery_pathname", "/")
316-
):
317-
return False # Signal that fallback should be attempted
275+
# OIDC 1.0 fallback (appends to full URL per OIDC spec)
276+
oidc_fallback = f"{auth_server_url.rstrip('/')}/.well-known/openid-configuration"
277+
urls.append(oidc_fallback)
318278

319-
return True # Signal no fallback needed (either success or non-404 error)
279+
return urls
320280

321281
async def _register_client(self) -> httpx.Request | None:
322282
"""Build registration request or skip if already registered."""
@@ -570,6 +530,17 @@ def _add_auth_header(self, request: httpx.Request) -> None:
570530
if self.context.current_tokens and self.context.current_tokens.access_token:
571531
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
572532

533+
def _create_oauth_metadata_request(self, url: str) -> httpx.Request:
534+
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
535+
536+
async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None:
537+
content = await response.aread()
538+
metadata = OAuthMetadata.model_validate_json(content)
539+
self.context.oauth_metadata = metadata
540+
# Apply default scope if needed
541+
if self.context.client_metadata.scope is None and metadata.scopes_supported is not None:
542+
self.context.client_metadata.scope = " ".join(metadata.scopes_supported)
543+
573544
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
574545
"""HTTPX auth flow integration."""
575546
async with self.context.lock:
@@ -603,15 +574,19 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
603574
await self._handle_protected_resource_response(discovery_response)
604575

605576
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
606-
oauth_request = await self._discover_oauth_metadata()
607-
oauth_response = yield oauth_request
608-
handled = await self._handle_oauth_metadata_response(oauth_response, is_fallback=False)
609-
610-
# If path-aware discovery failed with 404, try fallback to root
611-
if not handled:
612-
fallback_request = await self._discover_oauth_metadata_fallback()
613-
fallback_response = yield fallback_request
614-
await self._handle_oauth_metadata_response(fallback_response, is_fallback=True)
577+
discovery_urls = self._get_discovery_urls()
578+
for url in discovery_urls:
579+
oauth_metadata_request = self._create_oauth_metadata_request(url)
580+
oauth_metadata_response = yield oauth_metadata_request
581+
582+
if oauth_metadata_response.status_code == 200:
583+
try:
584+
await self._handle_oauth_metadata_response(oauth_metadata_response)
585+
break
586+
except ValidationError:
587+
continue
588+
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
589+
break # Non-4XX error, stop trying
615590

616591
# Step 3: Register client if needed
617592
registration_request = await self._register_client()
@@ -626,6 +601,6 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
626601
logger.exception("OAuth flow error")
627602
raise
628603

629-
# Retry with new tokens
630-
self._add_auth_header(request)
631-
yield request
604+
# Retry with new tokens
605+
self._add_auth_header(request)
606+
yield request

src/mcp/server/streamable_http_manager.py

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

55
import contextlib
66
import logging
7-
import threading
87
from collections.abc import AsyncIterator
98
from http import HTTPStatus
109
from typing import Any
@@ -75,7 +74,7 @@ def __init__(
7574
# The task group will be set during lifespan
7675
self._task_group = None
7776
# Thread-safe tracking of run() calls
78-
self._run_lock = threading.Lock()
77+
self._run_lock = anyio.Lock()
7978
self._has_started = False
8079

8180
@contextlib.asynccontextmanager
@@ -97,7 +96,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
9796
yield
9897
"""
9998
# Thread-safe check to ensure run() is only called once
100-
with self._run_lock:
99+
async with self._run_lock:
101100
if self._has_started:
102101
raise RuntimeError(
103102
"StreamableHTTPSessionManager .run() can only be called "

src/mcp/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
ProgressToken = str | int
3737
Cursor = str
3838
Role = Literal["user", "assistant"]
39-
RequestId = Annotated[int | str, Field(union_mode="left_to_right")]
39+
RequestId = Annotated[int, Field(strict=True)] | str
4040
AnyFunction: TypeAlias = Callable[..., Any]
4141

4242

@@ -849,7 +849,7 @@ class Tool(BaseMetadata):
849849
"""A JSON Schema object defining the expected parameters for the tool."""
850850
outputSchema: dict[str, Any] | None = None
851851
"""
852-
An optional JSON Schema object defining the structure of the tool's output
852+
An optional JSON Schema object defining the structure of the tool's output
853853
returned in the structuredContent field of a CallToolResult.
854854
"""
855855
annotations: ToolAnnotations | None = None

0 commit comments

Comments
 (0)