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
Copy file name to clipboardExpand all lines: docs/client/oauth-clients.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -83,6 +83,8 @@ The first time `Client` sends a request, the server answers `401`. The provider
83
83
84
84
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.
85
85
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
+
86
88
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.
Copy file name to clipboardExpand all lines: docs/client/transports.md
+25-3Lines changed: 25 additions & 3 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: `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.
33
33
34
34
!!! check
35
35
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_
45
45
46
46
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`:
47
47
48
-
```python title="client.py" hl_lines="8-14"
48
+
```python title="client.py" hl_lines="8-13"
49
49
--8<--"docs_src/client_transports/tutorial003.py"
50
50
```
51
51
@@ -75,9 +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. It is also where OAuth plugs in:
78
+
nothing away, except [redirect handling](#redirects). It is also where OAuth plugs in:
79
79
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
80
80
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
+
81
102
## stdio
82
103
83
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.
@@ -115,6 +136,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair
115
136
*`Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
116
137
*`Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
117
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 (a trailing-slash `307`/`308`), plus `http`→`https` on the same host. Anything else fails with `Redirect to … not followed`; configure the final URL.
118
140
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
119
141
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
120
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.
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.
2107
2106
2108
2107
`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:
2109
2108
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.
2111
2110
-`httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
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