Follow redirects only within the MCP endpoint's origin (#3397)

This commit is contained in:
Max 2026-09-04 23:43:38 +01:00 • committed by GitHub
commit c6762e821c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1029 additions and 205 deletions

View file

@ -83,6 +83,8 @@ The first time `Client` sends a request, the server answers `401`. The provider
After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.
One transport rule applies to all of these requests: like the MCP request they run inside, they follow a redirect only when it stays on the same origin and keeps the method (a trailing-slash 307/308, say), and treat any other redirect as that URL not answering.
You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below.
### Try it

View file

@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
--8<-- "docs_src/client_transports/tutorial002.py"
```
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
!!! check
A `Client` you have constructed is **not** connected. Construction only picks the transport;
@ -45,7 +45,7 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_
The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx2.AsyncClient` yourself and hand it to `streamable_http_client`:
```python title="client.py" hl_lines="8-14"
```python title="client.py" hl_lines="8-13"
--8<-- "docs_src/client_transports/tutorial003.py"
```
@ -75,9 +75,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
!!! info
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes
nothing away. It is also where OAuth plugs in:
nothing away, except [redirect handling](#redirects). It is also where OAuth plugs in:
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
### Redirects
The transport connects to the URL you gave it, and only that origin.
* A `307`/`308` redirect that stays on the same scheme, host and port is followed, and so is `http://` → `https://` on the same host. That covers the usual `/mcp` → `/mcp/` trailing-slash redirect.
* A redirect anywhere else is **not** followed. The call fails with:
```text
MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server
```
If that URL is the server you meant, put it in your config. If it isn't, the server or a proxy in front of it is misconfigured.
This holds for any `httpx2.AsyncClient` you pass in: its `follow_redirects` setting is not consulted for MCP requests, in either direction. The SDK's OAuth providers apply the same rule to their own requests.
!!! tip
`Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` means the
server sits behind a TLS-terminating proxy it doesn't know about and is issuing `http://` redirects.
That is fixed on the server (**[Deploy & scale](../run/deploy.md#behind-a-tls-terminating-proxy)**),
or by using the exact `https://…/` URL the message suggests.
## stdio
A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
@ -115,6 +136,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
* Redirects are followed only within the URL's own origin (a trailing-slash `307`/`308`), plus `http`→`https` on the same host. Anything else fails with `Redirect to … not followed`; configure the final URL.
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object, a URL or `StdioServerParameters` straight to that protocol.

View file

@ -119,7 +119,7 @@ them:
```python
import httpx
http_client = httpx.AsyncClient(follow_redirects=True)
http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
```
**After (v2):**
@ -127,7 +127,7 @@ http_client = httpx.AsyncClient(follow_redirects=True)
```python
import httpx2
http_client = httpx2.AsyncClient(follow_redirects=True)
http_client = httpx2.AsyncClient(timeout=httpx2.Timeout(30, read=300))
```
`httpx2` is API-compatible with `httpx`, so usually only the import name
@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient(
headers={"Authorization": "Bearer token"},
timeout=httpx2.Timeout(30, read=300),
auth=my_auth,
follow_redirects=True,
)
async with http_client:
@ -2103,11 +2102,11 @@ async with http_client:
...
```
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior.
v1's internal client set `follow_redirects=True`. You don't need it on your own client: the transport follows a method-preserving redirect within the endpoint's origin (a trailing-slash 307/308, say) itself, and does not follow one anywhere else, whatever the client is configured to do.
`streamable_http_client` itself keeps a small signature — `streamable_http_client(url, *, http_client=None, terminate_on_close=True)` — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build:
- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`.
- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts.
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
- `terminate_on_close`: unchanged (default `True`).
@ -2151,10 +2150,7 @@ async def capture_session_id(response: httpx2.Response) -> None:
if session_id:
captured_session_ids.append(session_id)
http_client = httpx2.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]})
async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):

View file

@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
--8<-- "docs_src/asgi/tutorial004.py"
```
Now clients connect to `/notes`, not `/notes/mcp`.
Now clients connect to `/notes/`, not `/notes/mcp`.
## CORS for browser clients

View file

@ -42,6 +42,22 @@ Deployed behind a real hostname, that same default rejects **every request** unt
deployed server that refuses every connection is a Host allowlist until proven otherwise.
**[Troubleshooting](../troubleshooting.md)** starts here too.
## Behind a TLS-terminating proxy
If TLS ends at a proxy (an ingress, a load balancer, Caddy, nginx) and uvicorn serves plain HTTP behind it, tell uvicorn to trust the proxy's `X-Forwarded-*` headers:
```console
uvicorn server:app --proxy-headers --forwarded-allow-ips='<proxy address>'
```
Without that, the app believes it is being served over `http://`, and any redirect it issues (the usual one is `/mcp` → `/mcp/`) points at `http://…`. The Python client refuses to follow an HTTPS endpoint to plain HTTP and says so:
```text
MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP.
```
The client-side stopgap is to configure the exact URL the server serves (`https://mcp.example.com/mcp/`, slash included) so no redirect happens. The fix is the flag above. `FORWARDED_ALLOW_IPS` is the environment-variable spelling; `*` trusts every hop, which is only right when nothing but the proxy can reach uvicorn.
## Workers, and who has to be sticky
Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork:
@ -165,6 +181,7 @@ An `MCPServer` is a protocol implementation, not an application server. The depl
## Recap
* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log.
* Behind a TLS-terminating proxy, run uvicorn with `--proxy-headers --forwarded-allow-ips=...`, or its redirects point at `http://` and the client refuses them.
* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read.
* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*.
* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name.

View file

@ -8,7 +8,6 @@ async def main() -> None:
async with httpx2.AsyncClient(
headers={"Authorization": "Bearer ..."},
timeout=httpx2.Timeout(30.0, read=300.0),
follow_redirects=True,
) as http_client:
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
async with Client(transport) as client:

View file

@ -62,7 +62,7 @@ oauth = IdentityAssertionOAuthProvider(
async def main() -> None:
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()

View file

@ -55,7 +55,7 @@ oauth = OAuthClientProvider(
async def main() -> None:
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()

View file

@ -35,7 +35,7 @@ oauth = ClientCredentialsOAuthProvider(
async def main() -> None:
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()

View file

@ -233,7 +233,7 @@ class SimpleAuthClient:
await self._run_session(read_stream, write_stream)
else:
print("📡 Opening StreamableHTTP transport connection with auth...")
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
read_stream,
write_stream,

View file

@ -1,15 +1,16 @@
import anyio
import click
import httpx2
import mcp.types as types
from mcp.server import Server, ServerRequestContext
from mcp.shared._httpx_utils import create_mcp_http_client
async def fetch_website(
url: str,
) -> list[types.ContentBlock]:
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
async with create_mcp_http_client(headers=headers) as client:
timeout = httpx2.Timeout(30, read=300)
async with httpx2.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
return [types.TextContent(type="text", text=response.text)]

View file

@ -66,7 +66,7 @@ async def main() -> None:
scope="user",
)
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth_auth) as http_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()

View file

@ -72,7 +72,7 @@ async def main():
callback_handler=handle_callback,
)
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()

View file

@ -39,6 +39,7 @@ from mcp.client.auth.utils import (
union_scopes,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url
@ -56,7 +57,7 @@ def _origin(url: str) -> tuple[str, str, int | None]:
return (parsed.scheme, parsed.hostname or "", port)
class IdentityAssertionOAuthProvider(httpx2.Auth):
class IdentityAssertionOAuthProvider(RedirectAwareAuth):
"""`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS.
The authorization server `issuer` is fixed at construction; metadata is fetched from its
@ -159,7 +160,7 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
data["client_secret"] = self._client.client_secret
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers)
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
async with self._lock:
if not self._initialized:
self._tokens = await self._storage.get_tokens()
@ -201,7 +202,9 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
token_response = yield self._build_token_request(scope_to_request, assertion)
if token_response.status_code != 200:
body = (await token_response.aread()).decode(errors="replace")
raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}")
raise OAuthTokenError(
f"Token exchange failed ({token_response.status_code}){redirect_note(token_response)}: {body}"
)
tokens = await handle_token_response_scopes(token_response)
if tokens.scope is None:
tokens.scope = scope_to_request

View file

@ -42,6 +42,7 @@ from mcp.client.auth.utils import (
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
@ -287,7 +288,7 @@ def _origin_issuer(server_url: str) -> str:
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))
class OAuthClientProvider(httpx2.Auth):
class OAuthClientProvider(RedirectAwareAuth):
"""OAuth2 authentication for httpx2.
Handles OAuth flow with automatic client registration and token storage.
@ -480,7 +481,9 @@ class OAuthClientProvider(httpx2.Auth):
if response.status_code not in {200, 201}:
body = await response.aread()
body_text = body.decode("utf-8")
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}")
raise OAuthTokenError(
f"Token exchange failed ({response.status_code}){redirect_note(response)}: {body_text}"
)
# Parse and validate response with scope validation
token_response = await handle_token_response_scopes(response)
@ -530,7 +533,7 @@ class OAuthClientProvider(httpx2.Auth):
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
"""Handle token refresh response. Returns True if successful."""
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
logger.warning(f"Token refresh failed: {response.status_code}{redirect_note(response)}")
self.context.clear_tokens()
return False
@ -598,8 +601,8 @@ class OAuthClientProvider(httpx2.Auth):
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
return self.context.auth_server_url or _origin_issuer(self.context.server_url)
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""httpx2 auth flow integration."""
async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`)."""
async with self.context.lock:
if not self._initialized:
await self._initialize()

View file

@ -8,6 +8,7 @@ from pydantic import AnyUrl, ValidationError
from pydantic_core import from_json
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.shared._httpx_utils import redirect_note
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
@ -230,9 +231,9 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth
return True, asm
except ValidationError: # pragma: no cover
return True, None
elif response.status_code < 400 or response.status_code >= 500:
return False, None # Non-4XX error, stop trying
return True, None
elif 300 <= response.status_code < 500:
return True, None # Not served at this URL (redirects are not followed) - try the next candidate
return False, None # Server error or unexpected status, stop trying
def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None:
@ -297,7 +298,9 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
"""Handle registration response."""
if response.status_code not in (200, 201):
await response.aread()
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
raise OAuthRegistrationError(
f"Registration failed: {response.status_code}{redirect_note(response)} {response.text}"
)
try:
content = await response.aread()

View file

@ -12,7 +12,12 @@ from httpx2 import SSEError
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import create_context_streams
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
from mcp.shared._httpx_utils import (
McpHttpClientFactory,
create_mcp_http_client,
request_within_origin,
sse_within_origin,
)
from mcp.shared.message import SessionMessage
logger = logging.getLogger(__name__)
@ -47,7 +52,14 @@ async def sse_client(
headers: Optional headers to include in requests.
timeout: HTTP timeout for regular operations (in seconds).
sse_read_timeout: Timeout for SSE read operations (in seconds).
httpx_client_factory: Factory function for creating the httpx2 client.
httpx_client_factory: Factory function for creating the httpx2 client. Whichever client it
returns, MCP requests follow a redirect only when it stays on the endpoint's origin
(same scheme, host and port, or http to https on the same host with default ports) and
keeps the request method (any status for the SSE GET, 307/308 for a message POST); any
other redirect is not followed, so connecting fails with
`httpx2.HTTPStatusError` for the redirect response. The client's `follow_redirects`
setting is not consulted; the SDK's OAuth providers apply the same rule to the requests
they make.
auth: Optional httpx2 authentication handler.
on_session_created: Optional callback invoked with the session ID when received.
"""
@ -55,7 +67,7 @@ async def sse_client(
async with httpx_client_factory(
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
) as client:
async with client.sse(url) as event_source:
async with sse_within_origin(client, url) as event_source:
event_source.response.raise_for_status()
logger.debug("SSE connection established")
@ -121,13 +133,11 @@ async def sse_client(
async def _send_message(session_message: SessionMessage) -> None:
logger.debug(f"Sending client message: {session_message}")
response = await client.post(
response = await request_within_origin(
client,
"POST",
endpoint_url,
json=session_message.message.model_dump(
by_alias=True,
mode="json",
exclude_unset=True,
),
json=session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True),
)
response.raise_for_status()
logger.debug(f"Client message sent successfully: {response.status_code}")

View file

@ -33,7 +33,13 @@ from pydantic import ValidationError
from mcp.client._transport import TransportStreams
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared._httpx_utils import (
create_mcp_http_client,
redirect_location,
request_within_origin,
sse_within_origin,
stream_within_origin,
)
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
from mcp.shared.message import ClientMessageMetadata, SessionMessage
@ -62,6 +68,21 @@ class ResumptionError(StreamableHTTPError):
"""Raised when resumption request is invalid."""
def _unfollowed_redirect(response: httpx2.Response) -> str | None:
"""Describe a redirect `stream_within_origin` left unfollowed, or None if `response` is not one."""
location = redirect_location(response)
if location is None:
return None
if response.request.url.scheme == "https" and location.scheme == "http":
return (
f"Redirect to {location} not followed: it would downgrade this HTTPS endpoint to plain HTTP.\n"
"The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,\n"
f"often combined with a trailing-slash difference. Try {location.copy_with(scheme='https')} instead, "
"or fix the proxy settings."
)
return f"Redirect to {location} not followed; use that URL as the endpoint if it is the intended server"
@dataclass
class RequestContext:
"""Context for a request operation."""
@ -210,7 +231,11 @@ class StreamableHTTPTransport:
if last_event_id:
headers[LAST_EVENT_ID] = last_event_id
async with client.sse(self.url, headers=headers) as event_source:
async with sse_within_origin(client, self.url, headers=headers) as event_source:
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
# The same GET would be redirected again, so retrying cannot help.
logger.warning(f"GET stream not opened: {redirect}")
return
event_source.response.raise_for_status()
logger.debug("GET SSE connection established")
@ -253,7 +278,14 @@ class StreamableHTTPTransport:
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
original_request_id = ctx.session_message.message.id
async with ctx.client.sse(self.url, headers=headers) as event_source:
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
logger.warning(redirect)
assert original_request_id is not None
await self._resolve_abandoned_request(
ctx.read_stream_writer, original_request_id, redirect, code=INVALID_REQUEST
)
return
event_source.response.raise_for_status()
logger.debug("Resumption GET SSE connection established")
@ -320,7 +352,8 @@ class StreamableHTTPTransport:
if ctx.metadata is not None and ctx.metadata.headers is not None:
headers.update(ctx.metadata.headers)
async with ctx.client.stream(
async with stream_within_origin(
ctx.client,
"POST",
self.url,
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
@ -339,6 +372,14 @@ class StreamableHTTPTransport:
)
return
if (redirect := _unfollowed_redirect(response)) is not None:
logger.warning(redirect)
if isinstance(message, JSONRPCRequest):
await self._resolve_abandoned_request(
ctx.read_stream_writer, message.id, redirect, code=INVALID_REQUEST
)
return
if response.status_code >= 400:
if isinstance(message, JSONRPCRequest):
# A spec-correct server may return the JSON-RPC error in the
@ -501,7 +542,7 @@ class StreamableHTTPTransport:
headers[LAST_EVENT_ID] = last_event_id
try:
async with ctx.client.sse(self.url, headers=headers) as event_source:
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
event_source.response.raise_for_status()
logger.info("Reconnected to SSE stream")
@ -626,7 +667,7 @@ class StreamableHTTPTransport:
try:
headers = self._prepare_headers()
response = await client.delete(self.url, headers=headers)
response = await request_within_origin(client, "DELETE", self.url, headers=headers)
if response.status_code == 405:
logger.debug("Server does not allow session termination")
@ -650,6 +691,13 @@ async def streamable_http_client(
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
client with recommended MCP timeouts will be created. To configure headers,
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
Whichever client is used, MCP requests follow a redirect only when it stays on the
endpoint's origin (same scheme, host and port, or http to https on the same host with
default ports) and keeps the request method (307/308 for a POST; any status for the GET
stream); any other redirect is not followed and the message it answered fails with an
error naming the location. The
client's `follow_redirects` setting is not consulted; the SDK's OAuth providers apply the
same rule to the requests they make.
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
Yields:

View file

@ -1,5 +1,8 @@
"""Utilities for creating standardized httpx2 AsyncClient instances."""
"""Utilities for creating and using httpx2 AsyncClient instances in the MCP transports."""
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any, Protocol
import httpx2
@ -10,6 +13,12 @@ __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_REA
MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds)
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds)
# The headers httpx2.AsyncClient.sse() adds to an event-stream request.
_SSE_HEADERS = {"Accept": "text/event-stream", "Cache-Control": "no-store"}
# How many redirects one auth-flow request may follow within its origin (see RedirectAwareAuth).
_AUTH_REDIRECT_LIMIT = 5
class McpHttpClientFactory(Protocol): # pragma: no branch
def __call__( # pragma: no branch
@ -25,9 +34,12 @@ def create_mcp_http_client(
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
"""Create a standardized httpx2 AsyncClient with MCP defaults.
"""Create an httpx2 AsyncClient with the MCP transports' default timeouts.
Always enables follow_redirects and applies an SSE-friendly default timeout.
The client uses a 30-second timeout for connect/write/pool and a 300-second
read timeout, because a server may hold a response stream open. Redirect
following is left at the httpx2 default (off): the MCP transports follow
redirects within the endpoint's origin themselves, see `stream_within_origin`.
Args:
headers: Optional headers to include with all requests.
@ -36,60 +48,169 @@ def create_mcp_http_client(
auth: Optional authentication handler.
Returns:
Configured httpx2.AsyncClient instance with MCP defaults.
Configured httpx2.AsyncClient instance.
Note:
The returned AsyncClient must be used as a context manager to ensure
proper cleanup of connections.
Example:
Basic usage with MCP defaults:
```python
async with create_mcp_http_client() as client:
response = await client.get("https://api.example.com")
```
With custom headers:
```python
headers = {"Authorization": "Bearer token"}
async with create_mcp_http_client(headers) as client:
response = await client.get("/endpoint")
```
With both custom headers and timeout:
```python
timeout = httpx2.Timeout(60.0, read=300.0)
async with create_mcp_http_client(headers, timeout) as client:
response = await client.get("/long-request")
```
With authentication:
```python
from httpx2 import BasicAuth
auth = BasicAuth(username="user", password="pass")
async with create_mcp_http_client(headers, timeout, auth) as client:
response = await client.get("/protected-endpoint")
```
"""
# Set MCP defaults
kwargs: dict[str, Any] = {"follow_redirects": True}
# Handle timeout
if timeout is None:
kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
else:
kwargs["timeout"] = timeout
# Handle headers
timeout = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
kwargs: dict[str, Any] = {"timeout": timeout}
if headers is not None:
kwargs["headers"] = headers
# Handle authentication
if auth is not None: # pragma: no cover
kwargs["auth"] = auth
return httpx2.AsyncClient(**kwargs)
def _within_origin(url: httpx2.URL, location: httpx2.URL) -> bool:
"""Whether `location` is on `url`'s origin, or is its https upgrade on the default ports.
httpx2 normalises a scheme's default port to None and lower-cases hosts, so
plain tuple comparison is exact. The upgrade rule is the one httpx2 itself
uses to decide a redirect has not left the origin (`_is_https_redirect`).
"""
if (url.scheme, url.host, url.port) == (location.scheme, location.host, location.port):
return True
return (
url.host == location.host
and url.scheme == "http"
and url.port is None
and location.scheme == "https"
and location.port is None
)
def next_request_within_origin(response: httpx2.Response) -> httpx2.Request | None:
"""The request that follows `response`'s redirect, if it is one the MCP transports follow.
That is when httpx2 built a next request for it (a redirect status with a
Location), the next request keeps the method (307/308, or any redirect of a
GET: httpx2 turns a POST into a body-less GET for 301/302/303, which would
drop the message), its URL stays within the origin of the request just sent
(same scheme, host and port, or http to https on the same host with default
ports), and the Location carries no userinfo (which httpx2 would otherwise
send as Basic auth). None for anything else, including a non-redirect.
"""
next_request = response.next_request
if next_request is None:
return None
sent = response.request
if (
next_request.method != sent.method
or next_request.url.userinfo
or not _within_origin(sent.url, next_request.url)
):
return None
return next_request
@asynccontextmanager
async def stream_within_origin(
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any
) -> AsyncGenerator[httpx2.Response]:
"""`client.stream(...)`, following redirects only while they stay within the request's origin.
An MCP transport talks to one configured endpoint, and everything on a request
(headers, auth, body) was configured for that endpoint. A redirect that
`next_request_within_origin` accepts, such as a 307/308 trailing-slash
normalisation, is followed, at most `client.max_redirects` times. Any other
redirect (or one past that budget) is not followed: the redirect response
itself is yielded, the way httpx2 hands one back when `follow_redirects` is
off, and the caller treats it as the non-success it is. The client's own
`follow_redirects` setting is not consulted. Requests an `httpx2.Auth` flow
makes during the call are sent without following either; the SDK's OAuth
providers apply the same rule to their own requests.
"""
request = client.build_request(method, url, **kwargs)
followed = 0
while True:
response = await client.send(request, stream=True, follow_redirects=False)
next_request = next_request_within_origin(response)
if next_request is None or followed == client.max_redirects:
break
try:
# Drain the redirect body so the connection returns to the pool, as httpx2 does when it follows.
await response.aread()
finally:
await response.aclose()
request = next_request
followed += 1
try:
yield response
finally:
await response.aclose()
async def request_within_origin(
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any
) -> httpx2.Response:
"""`client.request(...)` with the redirect handling of `stream_within_origin`."""
async with stream_within_origin(client, method, url, **kwargs) as response:
await response.aread()
return response
@asynccontextmanager
async def sse_within_origin(
client: httpx2.AsyncClient, url: httpx2.URL | str, *, headers: dict[str, str] | None = None
) -> AsyncGenerator[httpx2.EventSource]:
"""`client.sse(url)` with the redirect handling of `stream_within_origin`."""
merged = httpx2.Headers(_SSE_HEADERS)
merged.update(headers or {})
async with stream_within_origin(client, "GET", url, headers=merged) as response:
yield httpx2.EventSource(response)
def redirect_location(response: httpx2.Response) -> httpx2.URL | None:
"""Where `response` redirects to, for use in a message: without userinfo, query or fragment,
which can carry state that does not belong in an error or a log line. None if not a redirect."""
if response.next_request is None:
return None
return response.next_request.url.copy_with(userinfo=b"", query=None, fragment=None)
def redirect_note(response: httpx2.Response) -> str:
"""A suffix naming the location of a redirect response that was not followed, else empty."""
location = redirect_location(response)
if location is None:
return ""
return f" (redirected to {location}; not followed)"
class RedirectAwareAuth(ABC, httpx2.Auth):
"""An `httpx2.Auth` whose own requests follow redirects the way MCP transport requests do.
The transports send every request with redirect following off and follow a
redirect themselves only within the endpoint's origin (`stream_within_origin`).
httpx2 applies that per-request setting to the requests an auth flow makes
too (metadata discovery, registration, token), so on their own those would
follow nothing. Subclasses write their flow as `_auth_flow`; this class
drives it and, for each request the flow makes other than the one being
authenticated, follows a redirect that `next_request_within_origin` accepts,
up to `_AUTH_REDIRECT_LIMIT` times. Any other redirect response is handed
to the flow as it is.
"""
@abstractmethod
def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""The subclass's flow, written as `httpx2.Auth.async_auth_flow` otherwise would be."""
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
flow = self._auth_flow(request)
try:
outgoing = await flow.__anext__()
while True:
response = yield outgoing
if outgoing is not request:
for _ in range(_AUTH_REDIRECT_LIMIT):
follow = next_request_within_origin(response)
if follow is None:
break
response = yield follow
outgoing = await flow.asend(response)
except StopAsyncIteration:
return
finally:
await flow.aclose()

View file

@ -3,6 +3,7 @@
import base64
import json
import time
from collections.abc import AsyncGenerator
from unittest import mock
from urllib.parse import parse_qs, quote, unquote, urlparse
@ -24,6 +25,7 @@ from mcp.client.auth.utils import (
extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth,
get_client_metadata_scopes,
handle_auth_metadata_response,
handle_registration_response,
is_valid_client_metadata_url,
should_use_client_metadata_url,
@ -824,6 +826,97 @@ class TestProtectedResourceMetadata:
assert "resource=" in content
async def _start_discovery(
provider: OAuthClientProvider,
) -> tuple[AsyncGenerator[httpx2.Request, httpx2.Response], httpx2.Request]:
"""Drive `provider`'s auth flow to the point where it has sent the MCP request, seen a 401 and
issued its first protected-resource-metadata request; returns (flow, that request)."""
provider.context.current_tokens = None
provider.context.token_expiry_time = None
provider._initialized = True
mcp_request = httpx2.Request("POST", "https://api.example.com/v1/mcp")
flow = provider.async_auth_flow(mcp_request)
sent = await flow.__anext__()
assert sent is mcp_request
# No resource_metadata hint, so discovery tries the path-based well-known URL, then the root one.
unauthorized = httpx2.Response(401, request=mcp_request)
prm_request = await flow.asend(unauthorized)
assert (prm_request.method, str(prm_request.url)) == (
"GET",
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
)
return flow, prm_request
async def _redirect(request: httpx2.Request, status: int, location: str) -> httpx2.Response:
"""A redirect answer to `request`, as httpx2 hands it back when it does not follow it."""
transport = httpx2.MockTransport(lambda r: httpx2.Response(status, headers={"location": location}))
async with httpx2.AsyncClient(transport=transport) as client:
return await client.send(request)
@pytest.mark.anyio
async def test_auth_flow_follows_a_same_origin_redirect_of_its_own_request(oauth_provider: OAuthClientProvider):
"""SDK-defined: a request the OAuth flow makes (here protected-resource metadata discovery)
follows a redirect that stays within its origin and keeps its method, like an MCP request."""
flow, prm_request = await _start_discovery(oauth_provider)
follow_up = await flow.asend(await _redirect(prm_request, 307, "/.well-known/oauth-protected-resource/v1/mcp/"))
assert (follow_up.method, str(follow_up.url)) == (
"GET",
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp/",
)
await flow.aclose()
@pytest.mark.anyio
async def test_auth_flow_does_not_follow_a_redirect_of_its_own_request_to_another_origin(
oauth_provider: OAuthClientProvider,
):
"""SDK-defined: a redirect of a flow request to another origin is handed to the flow unfollowed,
which treats it as "not served here" and moves to its next discovery URL."""
flow, prm_request = await _start_discovery(oauth_provider)
next_request = await flow.asend(await _redirect(prm_request, 307, "https://elsewhere.example/prm"))
assert (next_request.method, str(next_request.url)) == (
"GET",
"https://api.example.com/.well-known/oauth-protected-resource",
)
await flow.aclose()
@pytest.mark.anyio
async def test_auth_flow_stops_following_a_redirecting_request_after_a_few_hops(
oauth_provider: OAuthClientProvider,
):
"""SDK-defined: a flow request that keeps redirecting within its origin is followed a bounded
number of times; the redirect after that is handed to the flow unfollowed."""
flow, request = await _start_discovery(oauth_provider)
hops = 0
while str(request.url) != "https://api.example.com/.well-known/oauth-protected-resource":
request = await flow.asend(
await _redirect(request, 307, f"/.well-known/oauth-protected-resource/v1/mcp/{hops}")
)
hops += 1
assert hops == 6 # five followed, the sixth handed back and taken as "try the next URL"
await flow.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize(("status", "keep_trying"), [(404, True), (307, True), (500, False)])
async def test_auth_metadata_response_says_whether_to_try_the_next_discovery_url(
status: int, keep_trying: bool
) -> None:
"""SDK-defined: a 4xx or a 3xx (redirects are not followed on these requests) from a discovery
candidate means the metadata is not served there and the next well-known URL is tried; a 5xx
stops discovery."""
assert await handle_auth_metadata_response(httpx2.Response(status)) == (keep_trying, None)
@pytest.mark.parametrize(
("protocol_version", "expected"),
[
@ -977,35 +1070,33 @@ class TestRegistrationResponse:
@pytest.mark.anyio
async def test_handle_registration_response_reads_before_accessing_text(self):
"""Test that response.aread() is called before accessing response.text."""
"""The registration error carries the response text, which for a streamed response means
reading it first (a streamed httpx2 response raises ResponseNotRead otherwise)."""
response = httpx2.Response(400, stream=httpx2.ByteStream(b"Registration failed with error"))
# Track if aread() was called
class MockResponse(httpx2.Response):
def __init__(self):
self.status_code = 400
self._aread_called = False
self._text = "Registration failed with error"
with pytest.raises(OAuthRegistrationError) as exc_info:
await handle_registration_response(response)
async def aread(self):
self._aread_called = True
return b"test content"
assert str(exc_info.value) == snapshot("Registration failed: 400 Registration failed with error")
@property
def text(self):
if not self._aread_called:
raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover
return self._text
@pytest.mark.anyio
async def test_registration_error_names_an_unfollowed_redirect(self):
"""SDK-defined: when the registration endpoint answered with a redirect that was not followed,
the error says where it pointed (without userinfo or query) instead of only the bare status."""
request = httpx2.Request("POST", "https://as.example/register")
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(
lambda r: httpx2.Response(307, headers={"location": "https://u:p@elsewhere.example/register?state=x"})
)
) as client:
response = await client.send(request)
mock_response = MockResponse()
with pytest.raises(OAuthRegistrationError) as exc_info:
await handle_registration_response(response)
# This should call aread() before accessing text
with pytest.raises(Exception) as exc_info:
await handle_registration_response(mock_response)
# Verify aread() was called
assert mock_response._aread_called
# Verify the error message includes the response text
assert "Registration failed: 400" in str(exc_info.value)
assert str(exc_info.value) == snapshot(
"Registration failed: 307 (redirected to https://elsewhere.example/register; not followed) "
)
@pytest.mark.anyio

Some files were not shown because too many files have changed in this diff Show more