You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9dc7b84
Browse filesBrowse the repository at this point in the historyBrowse files
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.
Copy file name to clipboardExpand all lines: docs/client/transports.md
+24-4Lines changed: 24 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
29
29
--8<--"docs_src/client_transports/tutorial002.py"
30
30
```
31
31
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.
33
33
34
34
!!! check
35
35
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
75
75
!!! info
76
76
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
77
77
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:
81
79
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
82
80
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
+
83
102
## stdio
84
103
85
104
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
117
136
*`Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
118
137
*`Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
119
138
* 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.
120
140
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
121
141
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
122
142
* 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.
Copy file name to clipboardExpand all lines: docs/run/deploy.md
+17Lines changed: 17 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -42,6 +42,22 @@ Deployed behind a real hostname, that same default rejects **every request** unt
42
42
deployed server that refuses every connection is a Host allowlist until proven otherwise.
43
43
**[Troubleshooting](../troubleshooting.md)** starts here too.
44
44
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:
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
+
45
61
## Workers, and who has to be sticky
46
62
47
63
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
165
181
## Recap
166
182
167
183
* 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.
168
185
* 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.
169
186
* 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"*.
170
187
* 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