Skip to content

Commit 9dc7b84

Browse files
committed
docs: give redirect handling its own short section; add the proxy-headers note to Deploy
- Client transports: the redirect rule moves out of the intro paragraph and the httpx2 info box into a small "Redirects" section (what is followed, what is not, the error you see, the fix), so it is linkable and findable. - Deploy & scale: a short "Behind a TLS-terminating proxy" section with the uvicorn --proxy-headers / --forwarded-allow-ips fix for servers whose redirects point at http://, which is what the client's downgrade message links to.
1 parent 1051429 commit 9dc7b84

2 files changed

Lines changed: 41 additions & 4 deletions

File tree

‎docs/client/transports.md‎

Lines changed: 24 additions & 4 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: a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. Whichever client is underneath, the transport follows 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 the default ports) and keeps the request method, which covers a 307/308 trailing-slash redirect. Any other redirect is not followed, and the call it answered fails with an `MCPError` naming the location; if that address is the server you meant, use it as the URL.
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;
@@ -75,11 +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, with one exception: redirects. MCP requests follow the same-origin rule above rather
79-
than the client's `follow_redirects`, and the requests the SDK's OAuth providers make while one is
80-
in flight (discovery, registration, token) follow that rule too. It is also where OAuth plugs in:
78+
nothing away, except [redirect handling](#redirects). It is also where OAuth plugs in:
8179
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
8280

81+
### Redirects
82+
83+
The transport connects to the URL you gave it, and only that origin.
84+
85+
* A redirect that stays on the same scheme, host and port is followed. That covers the usual `/mcp` → `/mcp/` trailing-slash redirect, and `http://` → `https://` on the same host.
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+
83102
## stdio
84103
85104
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.
@@ -117,6 +136,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair
117136
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
118137
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
119138
* 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 (trailing slash, `http`→`https`). Anything else fails with `Redirect to … not followed`; configure the final URL.
120140
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
121141
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
122142
* 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/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.

0 commit comments

Comments
 (0)