Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-14 18:05:08 +02:00 • committed by GitHub
commit 2713b53b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1029 additions and 948 deletions

View file

@ -6,7 +6,7 @@ auth_server_provider=...)` constructor call co-hosts the RFC 9728
protected-resource metadata route, the AS routes (`/register`, `/authorize`,
`/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated
`/mcp` endpoint on a single Starlette app. On the **client** side:
`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by
`OAuthClientProvider` is an `httpx2.Auth` that reacts to the first `401` by
walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange →
bearer retry — all inside the first awaited request, with no user-visible
`UnauthorizedError`.
@ -39,7 +39,7 @@ straight back with `?code=...`; without it the authorize step returns
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
can't carry the flow. Both runners close that gap the same way: `run_client`
(above) and the pytest harness build an authed `httpx.AsyncClient` from
(above) and the pytest harness build an authed `httpx2.AsyncClient` from
this module's `build_auth` export and hand `main` targets that are already
routed through it.
@ -57,9 +57,9 @@ routed through it.
request — no second `/authorize`, no second `/register`. The demo AS mints a
fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id`
is the reuse proof.
- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`.
- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx2.Auth`.
`Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands
the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand.
the auth has to be threaded onto the underlying `httpx2.AsyncClient` by hand.
- **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The
constructor wires everything; `streamable_http_app()` reads it back. (Don't
also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are
@ -74,7 +74,7 @@ routed through it.
## Caveats
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a
and the in-process httpx2 bridge sends no `Origin` header. Drop the kwarg for a
real deployment.
- `HeadlessOAuth` only works because the demo AS auto-consents; a real
`redirect_handler` would open a browser and a real `callback_handler` would

View file

@ -1,6 +1,6 @@
"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`."""
import httpx
import httpx2
from pydantic import AnyUrl
from mcp.client import Client
@ -14,11 +14,11 @@ from stories._harness import TargetFactory, run_client
from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage
def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth:
def build_auth(http_client: httpx2.AsyncClient) -> httpx2.Auth:
"""An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly.
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
`httpx.AsyncClient` and every target `main` receives is already routed through it.
`httpx2.AsyncClient` and every target `main` receives is already routed through it.
"""
headless = HeadlessOAuth()
headless.bind(http_client)