Skip to content
2 changes: 2 additions & 0 deletions docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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.

### Try it
Expand Down
28 changes: 25 additions & 3 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

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
A `Client` you have constructed is **not** connected. Construction only picks the transport;
Expand All @@ -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`:

```python title="client.py" hl_lines="8-14"
```python title="client.py" hl_lines="8-13"
--8<-- "docs_src/client_transports/tutorial003.py"
```

Expand Down Expand Up @@ -75,9 +75,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
!!! info
`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
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)**.

### 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.
Comment thread
maxisbey marked this conversation as resolved.
* 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

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.
Expand Down Expand Up @@ -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("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.
* 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.
* 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.
Expand Down
14 changes: 5 additions & 9 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,15 @@ them:
```python
import httpx

http_client = httpx.AsyncClient(follow_redirects=True)
http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
```

**After (v2):**

```python
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
Expand Down Expand Up @@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient(
headers={"Authorization": "Bearer token"},
timeout=httpx2.Timeout(30, read=300),
auth=my_auth,
follow_redirects=True,
)

async with http_client:
Expand All @@ -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:

- `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`.
- `terminate_on_close`: unchanged (default `True`).

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

http_client = httpx2.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]})

async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
Expand Down
2 changes: 1 addition & 1 deletion docs/run/asgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
--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

Expand Down
17 changes: 17 additions & 0 deletions docs/run/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
**[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

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:
Expand Down Expand Up @@ -165,6 +181,7 @@ An `MCPServer` is a protocol implementation, not an application server. The depl
## 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.
* 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.
* 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.
Expand Down
1 change: 0 additions & 1 deletion docs_src/client_transports/tutorial003.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ async def main() -> None:
async with httpx2.AsyncClient(
headers={"Authorization": "Bearer ..."},
timeout=httpx2.Timeout(30.0, read=300.0),
follow_redirects=True,
) as http_client:
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
async with Client(transport) as client:
Expand Down
2 changes: 1 addition & 1 deletion docs_src/identity_assertion/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str:


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)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
2 changes: 1 addition & 1 deletion docs_src/oauth_clients/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def wait_for_callback() -> AuthorizationCodeResult:


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)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
2 changes: 1 addition & 1 deletion docs_src/oauth_clients/tutorial002.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> 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)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
await self._run_session(read_stream, write_stream)
else:
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 (
read_stream,
write_stream,
Expand Down
5 changes: 3 additions & 2 deletions examples/servers/simple-tool/mcp_simple_tool/server.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import anyio
import click
import httpx2
import mcp.types as types
from mcp.server import Server, ServerRequestContext
from mcp.shared._httpx_utils import create_mcp_http_client


async def fetch_website(
url: str,
) -> list[types.ContentBlock]:
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.raise_for_status()
return [types.TextContent(type="text", text=response.text)]
Expand Down
2 changes: 1 addition & 1 deletion examples/snippets/clients/identity_assertion_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async def main() -> None:
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 ClientSession(read, write) as session:
await session.initialize()
Expand Down
2 changes: 1 addition & 1 deletion examples/snippets/clients/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def main():
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 ClientSession(read, write) as session:
await session.initialize()
Expand Down
9 changes: 6 additions & 3 deletions src/mcp/client/auth/extensions/identity_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
union_scopes,
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_utils import calculate_token_expiry, resource_url_from_server_url

Expand All @@ -56,7 +57,7 @@ def _origin(url: str) -> tuple[str, str, int | None]:
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.

The authorization server `issuer` is fixed at construction; metadata is fetched from its
Expand Down Expand Up @@ -159,7 +160,7 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Requ
data["client_secret"] = self._client.client_secret
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:
if not self._initialized:
self._tokens = await self._storage.get_tokens()
Expand Down Expand Up @@ -201,7 +202,9 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
token_response = yield self._build_token_request(scope_to_request, assertion)
if token_response.status_code != 200:
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)
if tokens.scope is None:
tokens.scope = scope_to_request
Expand Down
13 changes: 8 additions & 5 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
Expand Down Expand Up @@ -287,7 +288,7 @@ def _origin_issuer(server_url: str) -> str:
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))


class OAuthClientProvider(httpx2.Auth):
class OAuthClientProvider(RedirectAwareAuth):
"""OAuth2 authentication for httpx2.

Handles OAuth flow with automatic client registration and token storage.
Expand Down Expand Up @@ -480,7 +481,9 @@ async def _handle_token_response(self, response: httpx2.Response) -> None:
if response.status_code not in {200, 201}:
body = await response.aread()
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
token_response = await handle_token_response_scopes(response)
Expand Down Expand Up @@ -530,7 +533,7 @@ async def _refresh_token(self) -> httpx2.Request:
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
"""Handle token refresh response. Returns True if successful."""
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()
return False

Expand Down Expand Up @@ -598,8 +601,8 @@ def _expected_issuer(self) -> str:
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)

async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""httpx2 auth flow integration."""
async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`)."""
async with self.context.lock:
if not self._initialized:
await self._initialize()
Expand Down
Loading
Loading