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. 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. 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 ### 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" --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 !!! check
A `Client` you have constructed is **not** connected. Construction only picks the transport; 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`: 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" --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 !!! info
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth, `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 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)**. `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 ## 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. 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(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. * `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. * 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. * 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. * 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. * 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 ```python
import httpx import httpx
http_client = httpx.AsyncClient(follow_redirects=True) http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
``` ```
**After (v2):** **After (v2):**
@ -127,7 +127,7 @@ http_client = httpx.AsyncClient(follow_redirects=True)
```python ```python
import httpx2 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 `httpx2` is API-compatible with `httpx`, so usually only the import name
@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient(
headers={"Authorization": "Bearer token"}, headers={"Authorization": "Bearer token"},
timeout=httpx2.Timeout(30, read=300), timeout=httpx2.Timeout(30, read=300),
auth=my_auth, auth=my_auth,
follow_redirects=True,
) )
async with http_client: 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: `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`. - `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
- `terminate_on_close`: unchanged (default `True`). - `terminate_on_close`: unchanged (default `True`).
@ -2151,10 +2150,7 @@ async def capture_session_id(response: httpx2.Response) -> None:
if session_id: if session_id:
captured_session_ids.append(session_id) captured_session_ids.append(session_id)
http_client = httpx2.AsyncClient( http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]})
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
async with http_client: async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): 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" --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 ## 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. deployed server that refuses every connection is a Host allowlist until proven otherwise.
**[Troubleshooting](../troubleshooting.md)** starts here too. **[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 ## 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: 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 ## 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. * 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. * 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 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. * 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( async with httpx2.AsyncClient(
headers={"Authorization": "Bearer ..."}, headers={"Authorization": "Bearer ..."},
timeout=httpx2.Timeout(30.0, read=300.0), timeout=httpx2.Timeout(30.0, read=300.0),
follow_redirects=True,
) as http_client: ) as http_client:
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
async with Client(transport) as client: async with Client(transport) as client:

View file

@ -62,7 +62,7 @@ oauth = IdentityAssertionOAuthProvider(
async def main() -> None: 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) transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client: async with Client(transport) as client:
result = await client.list_tools() result = await client.list_tools()

View file

@ -55,7 +55,7 @@ oauth = OAuthClientProvider(
async def main() -> None: 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) transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client: async with Client(transport) as client:
result = await client.list_tools() result = await client.list_tools()

View file

@ -35,7 +35,7 @@ oauth = ClientCredentialsOAuthProvider(
async def main() -> None: 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) transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client: async with Client(transport) as client:
result = await client.list_tools() result = await client.list_tools()

View file

@ -233,7 +233,7 @@ class SimpleAuthClient:
await self._run_session(read_stream, write_stream) await self._run_session(read_stream, write_stream)
else: else:
print("📡 Opening StreamableHTTP transport connection with auth...") 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 ( async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
read_stream, read_stream,
write_stream, write_stream,

View file

@ -1,15 +1,16 @@
import anyio import anyio
import click import click
import httpx2
import mcp.types as types import mcp.types as types
from mcp.server import Server, ServerRequestContext from mcp.server import Server, ServerRequestContext
from mcp.shared._httpx_utils import create_mcp_http_client
async def fetch_website( async def fetch_website(
url: str, url: str,
) -> list[types.ContentBlock]: ) -> list[types.ContentBlock]:
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} 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 = await client.get(url)
response.raise_for_status() response.raise_for_status()
return [types.TextContent(type="text", text=response.text)] return [types.TextContent(type="text", text=response.text)]

View file

@ -66,7 +66,7 @@ async def main() -> None:
scope="user", 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 streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()

View file

@ -72,7 +72,7 @@ async def main():
callback_handler=handle_callback, 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 streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()

View file

@ -39,6 +39,7 @@ from mcp.client.auth.utils import (
union_scopes, union_scopes,
validate_metadata_issuer, 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 import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url 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) 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. """`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 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 data["client_secret"] = self._client.client_secret
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers) 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: async with self._lock:
if not self._initialized: if not self._initialized:
self._tokens = await self._storage.get_tokens() 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) token_response = yield self._build_token_request(scope_to_request, assertion)
if token_response.status_code != 200: if token_response.status_code != 200:
body = (await token_response.aread()).decode(errors="replace") 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) tokens = await handle_token_response_scopes(token_response)
if tokens.scope is None: if tokens.scope is None:
tokens.scope = scope_to_request tokens.scope = scope_to_request

View file

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

View file

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

View file

@ -12,7 +12,12 @@ from httpx2 import SSEError
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import create_context_streams 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 from mcp.shared.message import SessionMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -47,7 +52,14 @@ async def sse_client(
headers: Optional headers to include in requests. headers: Optional headers to include in requests.
timeout: HTTP timeout for regular operations (in seconds). timeout: HTTP timeout for regular operations (in seconds).
sse_read_timeout: Timeout for SSE read 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. auth: Optional httpx2 authentication handler.
on_session_created: Optional callback invoked with the session ID when received. 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( async with httpx_client_factory(
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout) headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
) as client: ) 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() event_source.response.raise_for_status()
logger.debug("SSE connection established") logger.debug("SSE connection established")
@ -121,13 +133,11 @@ async def sse_client(
async def _send_message(session_message: SessionMessage) -> None: async def _send_message(session_message: SessionMessage) -> None:
logger.debug(f"Sending client message: {session_message}") logger.debug(f"Sending client message: {session_message}")
response = await client.post( response = await request_within_origin(
client,
"POST",
endpoint_url, endpoint_url,
json=session_message.message.model_dump( json=session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True),
by_alias=True,
mode="json",
exclude_unset=True,
),
) )
response.raise_for_status() response.raise_for_status()
logger.debug(f"Client message sent successfully: {response.status_code}") 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.client._transport import TransportStreams
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams 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.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.message import ClientMessageMetadata, SessionMessage
@ -62,6 +68,21 @@ class ResumptionError(StreamableHTTPError):
"""Raised when resumption request is invalid.""" """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 @dataclass
class RequestContext: class RequestContext:
"""Context for a request operation.""" """Context for a request operation."""
@ -210,7 +231,11 @@ class StreamableHTTPTransport:
if last_event_id: if last_event_id:
headers[LAST_EVENT_ID] = 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() event_source.response.raise_for_status()
logger.debug("GET SSE connection established") logger.debug("GET SSE connection established")
@ -253,7 +278,14 @@ class StreamableHTTPTransport:
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
original_request_id = ctx.session_message.message.id 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() event_source.response.raise_for_status()
logger.debug("Resumption GET SSE connection established") 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: if ctx.metadata is not None and ctx.metadata.headers is not None:
headers.update(ctx.metadata.headers) headers.update(ctx.metadata.headers)
async with ctx.client.stream( async with stream_within_origin(
ctx.client,
"POST", "POST",
self.url, self.url,
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True), json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
@ -339,6 +372,14 @@ class StreamableHTTPTransport:
) )
return 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 response.status_code >= 400:
if isinstance(message, JSONRPCRequest): if isinstance(message, JSONRPCRequest):
# A spec-correct server may return the JSON-RPC error in the # 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 headers[LAST_EVENT_ID] = last_event_id
try: 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() event_source.response.raise_for_status()
logger.info("Reconnected to SSE stream") logger.info("Reconnected to SSE stream")
@ -626,7 +667,7 @@ class StreamableHTTPTransport:
try: try:
headers = self._prepare_headers() 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: if response.status_code == 405:
logger.debug("Server does not allow session termination") 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 http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
client with recommended MCP timeouts will be created. To configure headers, client with recommended MCP timeouts will be created. To configure headers,
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here. 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. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
Yields: 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 from typing import Any, Protocol
import httpx2 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_TIMEOUT = 30.0 # General operations (seconds)
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (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 class McpHttpClientFactory(Protocol): # pragma: no branch
def __call__( # pragma: no branch def __call__( # pragma: no branch
@ -25,9 +34,12 @@ def create_mcp_http_client(
timeout: httpx2.Timeout | None = None, timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None, auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient: ) -> 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: Args:
headers: Optional headers to include with all requests. headers: Optional headers to include with all requests.
@ -36,60 +48,169 @@ def create_mcp_http_client(
auth: Optional authentication handler. auth: Optional authentication handler.
Returns: Returns:
Configured httpx2.AsyncClient instance with MCP defaults. Configured httpx2.AsyncClient instance.
Note: Note:
The returned AsyncClient must be used as a context manager to ensure The returned AsyncClient must be used as a context manager to ensure
proper cleanup of connections. 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: if timeout is None:
kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) timeout = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
else: kwargs: dict[str, Any] = {"timeout": timeout}
kwargs["timeout"] = timeout
# Handle headers
if headers is not None: if headers is not None:
kwargs["headers"] = headers kwargs["headers"] = headers
# Handle authentication
if auth is not None: # pragma: no cover if auth is not None: # pragma: no cover
kwargs["auth"] = auth kwargs["auth"] = auth
return httpx2.AsyncClient(**kwargs) 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 base64
import json import json
import time import time
from collections.abc import AsyncGenerator
from unittest import mock from unittest import mock
from urllib.parse import parse_qs, quote, unquote, urlparse 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_resource_metadata_from_www_auth,
extract_scope_from_www_auth, extract_scope_from_www_auth,
get_client_metadata_scopes, get_client_metadata_scopes,
handle_auth_metadata_response,
handle_registration_response, handle_registration_response,
is_valid_client_metadata_url, is_valid_client_metadata_url,
should_use_client_metadata_url, should_use_client_metadata_url,
@ -824,6 +826,97 @@ class TestProtectedResourceMetadata:
assert "resource=" in content 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( @pytest.mark.parametrize(
("protocol_version", "expected"), ("protocol_version", "expected"),
[ [
@ -977,35 +1070,33 @@ class TestRegistrationResponse:
@pytest.mark.anyio @pytest.mark.anyio
async def test_handle_registration_response_reads_before_accessing_text(self): 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 with pytest.raises(OAuthRegistrationError) as exc_info:
class MockResponse(httpx2.Response): await handle_registration_response(response)
def __init__(self):
self.status_code = 400
self._aread_called = False
self._text = "Registration failed with error"
async def aread(self): assert str(exc_info.value) == snapshot("Registration failed: 400 Registration failed with error")
self._aread_called = True
return b"test content"
@property @pytest.mark.anyio
def text(self): async def test_registration_error_names_an_unfollowed_redirect(self):
if not self._aread_called: """SDK-defined: when the registration endpoint answered with a redirect that was not followed,
raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover the error says where it pointed (without userinfo or query) instead of only the bare status."""
return self._text 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 assert str(exc_info.value) == snapshot(
with pytest.raises(Exception) as exc_info: "Registration failed: 307 (redirected to https://elsewhere.example/register; not followed) "
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)
@pytest.mark.anyio @pytest.mark.anyio

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