Skip to content

Commit c6762e8

Browse files
authored
Follow redirects only within the MCP endpoint's origin (#3397)
1 parent 5fd3abc commit c6762e8

25 files changed

Lines changed: 1029 additions & 205 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ The first time `Client` sends a request, the server answers `401`. The provider
8383

8484
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.
8585

86+
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.
87+
8688
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.
8789

8890
### Try it

docs/client/transports.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
2929
--8<-- "docs_src/client_transports/tutorial002.py"
3030
```
3131

32-
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.
32+
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.
3333

3434
!!! check
3535
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_
4545

4646
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`:
4747

48-
```python title="client.py" hl_lines="8-14"
48+
```python title="client.py" hl_lines="8-13"
4949
--8<-- "docs_src/client_transports/tutorial003.py"
5050
```
5151

@@ -75,9 +75,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
7575
!!! info
7676
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
7777
proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes
78-
nothing away. It is also where OAuth plugs in:
78+
nothing away, except [redirect handling](#redirects). It is also where OAuth plugs in:
7979
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
8080

81+
### Redirects
82+
83+
The transport connects to the URL you gave it, and only that origin.
84+
85+
* 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.
86+
* A redirect anywhere else is **not** followed. The call fails with:
87+
88+
```text
89+
MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server
90+
```
91+
92+
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.
93+
94+
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.
95+
96+
!!! tip
97+
`Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` means the
98+
server sits behind a TLS-terminating proxy it doesn't know about and is issuing `http://` redirects.
99+
That is fixed on the server (**[Deploy & scale](../run/deploy.md#behind-a-tls-terminating-proxy)**),
100+
or by using the exact `https://…/` URL the message suggests.
101+
81102
## stdio
82103
83104
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
115136
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
116137
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
117138
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
139+
* 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.
118140
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
119141
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
120142
* 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.

docs/migration.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,15 @@ them:
119119
```python
120120
import httpx
121121

122-
http_client = httpx.AsyncClient(follow_redirects=True)
122+
http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
123123
```
124124

125125
**After (v2):**
126126

127127
```python
128128
import httpx2
129129

130-
http_client = httpx2.AsyncClient(follow_redirects=True)
130+
http_client = httpx2.AsyncClient(timeout=httpx2.Timeout(30, read=300))
131131
```
132132

133133
`httpx2` is API-compatible with `httpx`, so usually only the import name
@@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient(
20922092
headers={"Authorization": "Bearer token"},
20932093
timeout=httpx2.Timeout(30, read=300),
20942094
auth=my_auth,
2095-
follow_redirects=True,
20962095
)
20972096

20982097
async with http_client:
@@ -2103,11 +2102,11 @@ async with http_client:
21032102
...
21042103
```
21052104

2106-
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior.
2105+
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.
21072106

21082107
`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:
21092108

2110-
- `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`.
2109+
- `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.
21112110
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
21122111
- `terminate_on_close`: unchanged (default `True`).
21132112

@@ -2151,10 +2150,7 @@ async def capture_session_id(response: httpx2.Response) -> None:
21512150
if session_id:
21522151
captured_session_ids.append(session_id)
21532152

2154-
http_client = httpx2.AsyncClient(
2155-
event_hooks={"response": [capture_session_id]},
2156-
follow_redirects=True,
2157-
)
2153+
http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]})
21582154

21592155
async with http_client:
21602156
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):

docs/run/asgi.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
9494
--8<-- "docs_src/asgi/tutorial004.py"
9595
```
9696

97-
Now clients connect to `/notes`, not `/notes/mcp`.
97+
Now clients connect to `/notes/`, not `/notes/mcp`.
9898

9999
## CORS for browser clients
100100

docs/run/deploy.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ Deployed behind a real hostname, that same default rejects **every request** unt
4242
deployed server that refuses every connection is a Host allowlist until proven otherwise.
4343
**[Troubleshooting](../troubleshooting.md)** starts here too.
4444

45+
## Behind a TLS-terminating proxy
46+
47+
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:
48+
49+
```console
50+
uvicorn server:app --proxy-headers --forwarded-allow-ips='<proxy address>'
51+
```
52+
53+
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:
54+
55+
```text
56+
MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP.
57+
```
58+
59+
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.
60+
4561
## Workers, and who has to be sticky
4662

4763
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
165181
## Recap
166182

167183
* 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.
184+
* Behind a TLS-terminating proxy, run uvicorn with `--proxy-headers --forwarded-allow-ips=...`, or its redirects point at `http://` and the client refuses them.
168185
* 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.
169186
* 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"*.
170187
* 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.

docs_src/client_transports/tutorial003.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ async def main() -> None:
88
async with httpx2.AsyncClient(
99
headers={"Authorization": "Bearer ..."},
1010
timeout=httpx2.Timeout(30.0, read=300.0),
11-
follow_redirects=True,
1211
) as http_client:
1312
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
1413
async with Client(transport) as client:

docs_src/identity_assertion/tutorial001.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str:
6262

6363

6464
async def main() -> None:
65-
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
65+
async with httpx2.AsyncClient(auth=oauth) as http_client:
6666
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
6767
async with Client(transport) as client:
6868
result = await client.list_tools()

docs_src/oauth_clients/tutorial001.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async def wait_for_callback() -> AuthorizationCodeResult:
5555

5656

5757
async def main() -> None:
58-
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
58+
async with httpx2.AsyncClient(auth=oauth) as http_client:
5959
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
6060
async with Client(transport) as client:
6161
result = await client.list_tools()

docs_src/oauth_clients/tutorial002.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None
3535

3636

3737
async def main() -> None:
38-
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
38+
async with httpx2.AsyncClient(auth=oauth) as http_client:
3939
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
4040
async with Client(transport) as client:
4141
result = await client.list_tools()

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
233233
await self._run_session(read_stream, write_stream)
234234
else:
235235
print("📡 Opening StreamableHTTP transport connection with auth...")
236-
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
236+
async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
237237
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
238238
read_stream,
239239
write_stream,

0 commit comments

Comments
 (0)