Skip to content

Commit 7ce9d67

Browse files
committed
Merge branch 'main' of https://github.com/modelcontextprotocol/python-sdk into feat/oidc-fallback
2 parents 095c110 + 6566c08 commit 7ce9d67

File tree

4 files changed

+365
-60
lines changed

4 files changed

+365
-60
lines changed

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -943,10 +943,34 @@ The streamable HTTP transport supports:
943943

944944
### Mounting to an Existing ASGI Server
945945

946-
> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http).
947-
948946
By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below.
949947

948+
#### Streamable HTTP servers
949+
950+
The following example shows how to use `streamable_http_app()`, a method that returns a `Starlette` application object.
951+
You can then append additional routes to that application as needed.
952+
953+
```python
954+
mcp = FastMCP("My App")
955+
956+
app = mcp.streamable_http_app()
957+
# Additional non-MCP routes can be added like so:
958+
# from starlette.routing import Route
959+
# app.router.routes.append(Route("/", endpoint=other_route_function))
960+
```
961+
962+
To customize the route from the default of "/mcp", either specify the `streamable_http_path` option for the `FastMCP` constructor,
963+
or set `FASTMCP_STREAMABLE_HTTP_PATH` environment variable.
964+
965+
Note that in Starlette and FastAPI (which is based on Starlette), the "/mcp" route will redirect to "/mcp/",
966+
so you may need to use "/mcp/" when pointing MCP clients at your servers.
967+
968+
For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes).
969+
970+
#### SSE servers
971+
972+
> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http).
973+
950974
You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications.
951975

952976
```python

src/mcp/client/auth.py

Lines changed: 52 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import base64
88
import hashlib
99
import logging
10+
import re
1011
import secrets
1112
import string
1213
import time
@@ -206,10 +207,39 @@ def __init__(
206207
)
207208
self._initialized = False
208209

209-
async def _discover_protected_resource(self) -> httpx.Request:
210-
"""Build discovery request for protected resource metadata."""
211-
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
212-
url = urljoin(auth_base_url, "/.well-known/oauth-protected-resource")
210+
def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response) -> str | None:
211+
"""
212+
Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
213+
214+
Returns:
215+
Resource metadata URL if found in WWW-Authenticate header, None otherwise
216+
"""
217+
if not init_response or init_response.status_code != 401:
218+
return None
219+
220+
www_auth_header = init_response.headers.get("WWW-Authenticate")
221+
if not www_auth_header:
222+
return None
223+
224+
# Pattern matches: resource_metadata="url" or resource_metadata=url (unquoted)
225+
pattern = r'resource_metadata=(?:"([^"]+)"|([^\s,]+))'
226+
match = re.search(pattern, www_auth_header)
227+
228+
if match:
229+
# Return quoted value if present, otherwise unquoted value
230+
return match.group(1) or match.group(2)
231+
232+
return None
233+
234+
async def _discover_protected_resource(self, init_response: httpx.Response) -> httpx.Request:
235+
# RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
236+
url = self._extract_resource_metadata_from_www_auth(init_response)
237+
238+
if not url:
239+
# Fallback to well-known discovery
240+
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
241+
url = urljoin(auth_base_url, "/.well-known/oauth-protected-resource")
242+
213243
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
214244

215245
async def _handle_protected_resource_response(self, response: httpx.Response) -> None:
@@ -558,61 +588,26 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
558588
# Capture protocol version from request headers
559589
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
560590

561-
# Perform OAuth flow if not authenticated
562-
if not self.context.is_token_valid():
563-
try:
564-
# OAuth flow must be inline due to generator constraints
565-
# Step 1: Discover protected resource metadata (spec revision 2025-06-18)
566-
discovery_request = await self._discover_protected_resource()
567-
discovery_response = yield discovery_request
568-
await self._handle_protected_resource_response(discovery_response)
569-
570-
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
571-
oauth_discovery_stack = self._create_oauth_discovery_stack()
572-
while len(oauth_discovery_stack) > 0:
573-
oauth_discovery = oauth_discovery_stack.pop()
574-
oauth_request = await oauth_discovery()
575-
oauth_response = yield oauth_request
576-
await self._handle_oauth_metadata_response(oauth_response, oauth_discovery_stack)
577-
578-
# Step 3: Register client if needed
579-
registration_request = await self._register_client()
580-
if registration_request:
581-
registration_response = yield registration_request
582-
await self._handle_registration_response(registration_response)
583-
584-
# Step 4: Perform authorization
585-
auth_code, code_verifier = await self._perform_authorization()
586-
587-
# Step 5: Exchange authorization code for tokens
588-
token_request = await self._exchange_token(auth_code, code_verifier)
589-
token_response = yield token_request
590-
await self._handle_token_response(token_response)
591-
except Exception:
592-
logger.exception("OAuth flow error")
593-
raise
594-
595-
# Add authorization header and make request
596-
self._add_auth_header(request)
597-
response = yield request
598-
599-
# Handle 401 responses
600-
if response.status_code == 401 and self.context.can_refresh_token():
591+
if not self.context.is_token_valid() and self.context.can_refresh_token():
601592
# Try to refresh token
602593
refresh_request = await self._refresh_token()
603594
refresh_response = yield refresh_request
604595

605-
if await self._handle_refresh_response(refresh_response):
606-
# Retry original request with new token
607-
self._add_auth_header(request)
608-
yield request
609-
else:
596+
if not await self._handle_refresh_response(refresh_response):
610597
# Refresh failed, need full re-authentication
611598
self._initialized = False
612599

600+
if self.context.is_token_valid():
601+
self._add_auth_header(request)
602+
603+
response = yield request
604+
605+
if response.status_code == 401:
606+
# Perform full OAuth flow
607+
try:
613608
# OAuth flow must be inline due to generator constraints
614-
# Step 1: Discover protected resource metadata (spec revision 2025-06-18)
615-
discovery_request = await self._discover_protected_resource()
609+
# Step 1: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
610+
discovery_request = await self._discover_protected_resource(response)
616611
discovery_response = yield discovery_request
617612
await self._handle_protected_resource_response(discovery_response)
618613

@@ -637,7 +632,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
637632
token_request = await self._exchange_token(auth_code, code_verifier)
638633
token_response = yield token_request
639634
await self._handle_token_response(token_response)
635+
except Exception:
636+
logger.exception("OAuth flow error")
637+
raise
640638

641-
# Retry with new tokens
642-
self._add_auth_header(request)
643-
yield request
639+
# Add authorization header and make request
640+
self._add_auth_header(request)
641+
response = yield request

0 commit comments

Comments
 (0)