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
18 changed files with 1029 additions and 948 deletions

View file

@ -18,7 +18,7 @@ from typing import Any, TypeAlias
from urllib.parse import urlsplit
import anyio
import httpx
import httpx2
from mcp_types.version import LATEST_MODERN_VERSION
from mcp import StdioServerParameters, stdio_client
@ -38,8 +38,8 @@ Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
TargetFactory = Callable[[], Target]
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories)."""
AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth]
"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
AuthBuilder = Callable[[httpx2.AsyncClient], httpx2.Auth]
"""Builds an ``httpx2.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
def argv_after(flag: str, *, default: str | None = None) -> str:
@ -127,8 +127,8 @@ def _story_cfg(name: str) -> dict[str, Any]:
return manifest["defaults"] | manifest["story"].get(name, {})
def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory:
"""Fresh streamable-HTTP transports over an already-authed ``httpx`` client."""
def _authed_targets(url: str, http: httpx2.AsyncClient) -> TargetFactory:
"""Fresh streamable-HTTP transports over an already-authed ``httpx2`` client."""
return lambda: streamable_http_client(url, http_client=http)
@ -176,13 +176,13 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None:
if url is None or (build_auth is None and not cfg["needs_http"]):
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
return
# Auth and needs_http stories want the raw httpx client underneath the transport:
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
# Auth and needs_http stories want the raw httpx2 client underneath the transport:
# build_auth threads an httpx2.Auth onto it (Client(url, auth=...) doesn't exist
# yet), and needs_http stories assert on raw responses, so root the client at the
# server origin and relative paths like "/mcp" resolve.
parts = urlsplit(url)
base = f"{parts.scheme}://{parts.netloc}"
http = await stack.enter_async_context(httpx.AsyncClient(base_url=base))
http = await stack.enter_async_context(httpx2.AsyncClient(base_url=base))
make = targets
if build_auth is not None:
http.auth = build_auth(http)

View file

@ -25,7 +25,7 @@ ServerFactory = Callable[[], AnyServer]
AppFactory = Callable[[], Starlette]
NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header."""
"""Harness servers bind 127.0.0.1 and the in-process httpx2 client sends no Origin header."""
def argv_after(flag: str, *, default: str | None = None) -> str:

View file

@ -10,7 +10,7 @@ import secrets
import time
from urllib.parse import parse_qs, urlsplit
import httpx
import httpx2
from pydantic import AnyHttpUrl
from mcp.server.auth.provider import (
@ -49,14 +49,14 @@ class InMemoryTokenStorage:
class HeadlessOAuth:
"""Completes the authorize redirect in-process via the bound ``httpx`` client."""
"""Completes the authorize redirect in-process via the bound ``httpx2`` client."""
def __init__(self) -> None:
self.authorize_url: str | None = None
self._http: httpx.AsyncClient | None = None
self._http: httpx2.AsyncClient | None = None
self._result = AuthorizationCodeResult(code="", state=None)
def bind(self, http_client: httpx.AsyncClient) -> None:
def bind(self, http_client: httpx2.AsyncClient) -> None:
self._http = http_client
async def redirect_handler(self, authorization_url: str) -> None:

View file

@ -31,7 +31,7 @@ uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
can't carry the token. Both runners close that gap the same way: `run_client`
(above) and the pytest harness thread the module's `build_auth` export onto the
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
`httpx2.AsyncClient` underneath the transport and hand `main` a target that is
already routed through it.
## Try it without the SDK client
@ -56,9 +56,9 @@ kill "$SERVER_PID"
transport that already carries the bearer token; nothing in the body knows
auth exists.
- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is
five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
five lines of `httpx2.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
is missing; until it lands, the auth has to be threaded onto the
`httpx.AsyncClient` underneath the transport, outside `main`.
`httpx2.AsyncClient` underneath the transport, outside `main`.
- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the
whole recipe; `streamable_http_app()` reads those constructor kwargs and
mounts the bearer gate + PRM route.
@ -73,7 +73,7 @@ kill "$SERVER_PID"
## Caveats
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
for localhost binds; the harness disables it because the in-process httpx
for localhost binds; the harness disables it because the in-process httpx2
client sends no `Origin` header. Drop the kwarg for a real deployment.
- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin).
If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's

View file

@ -2,7 +2,7 @@
from collections.abc import Generator
import httpx
import httpx2
from mcp.client import Client
from stories._harness import Target, run_client
@ -10,22 +10,22 @@ from stories._harness import Target, run_client
from .server import DEMO_TOKEN, REQUIRED_SCOPE
class StaticBearerAuth(httpx.Auth):
"""``httpx.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
class StaticBearerAuth(httpx2.Auth):
"""``httpx2.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
def __init__(self, token: str) -> None:
self.token = token
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]:
request.headers["Authorization"] = f"Bearer {self.token}"
yield request
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
"""The demo bearer token as an ``httpx.Auth``.
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
"""The demo bearer token as an ``httpx2.Auth``.
``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying
``httpx.AsyncClient`` and the target ``main`` receives is already routed through it.
``httpx2.AsyncClient`` and the target ``main`` receives is already routed through it.
"""
return StaticBearerAuth(DEMO_TOKEN)

View file

@ -30,7 +30,7 @@ uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/
`Client(url)` has no `auth=` passthrough, so both runners thread the module's
`build_auth` export (an `IdentityAssertionOAuthProvider`) onto the
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
`httpx2.AsyncClient` underneath the transport and hand `main` a target that is
already routed through it.
## Try it without the SDK client
@ -57,7 +57,7 @@ kill "$SERVER_PID"
return a fresh ID-JAG. In production this is an RFC 8693 token exchange
against your IdP; here it calls the stand-in signer in `idp.py`.
- `client.py` `build_auth` — `IdentityAssertionOAuthProvider` is the same
`httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the
`httpx2.Auth` shape as every other provider. Note `issuer=ISSUER` with the
trailing slash: the provider compares it to the metadata document's `issuer`
by simple string comparison and refuses a mismatch before sending anything.
- `server.py` `exchange_identity_assertion` — the whole authorization-server

View file

@ -1,6 +1,6 @@
"""HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject."""
import httpx
import httpx2
from mcp.client import Client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
@ -27,14 +27,14 @@ async def fetch_id_jag(audience: str, resource: str) -> str:
)
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
"""An `IdentityAssertionOAuthProvider` for the pre-registered confidential client.
`issuer` is configuration, not discovery: the provider fetches metadata from this issuer's
well-known and never asks the MCP server which authorization server to use. The string must
equal the `issuer` its metadata serves byte for byte (note the trailing slash).
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
`httpx.AsyncClient` and hands `main` a target that is already routed through it.
`httpx2.AsyncClient` and hands `main` a target that is already routed through it.
"""
return IdentityAssertionOAuthProvider(
server_url=MCP_URL,

View file

@ -34,7 +34,7 @@ kill "$SERVER_PID"
- `client.py` `main` — `async with Client(target, mode=mode) as client:` is an
ordinary high-level client; nothing about JSON mode is visible from this side.
The same `main` also takes the raw `httpx.AsyncClient` so it can prove what
The same `main` also takes the raw `httpx2.AsyncClient` so it can prove what
the wire looks like underneath.
- `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire
shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize
@ -52,7 +52,7 @@ kill "$SERVER_PID"
## Caveats
- DNS-rebinding protection is on by default; the harness disables it via
`NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header.
`NO_DNS_REBIND` because the in-process httpx2 client sends no `Origin` header.
- The `streamable_http_app()` call shape here will move when the free-function
entry lands (see `_hosting.py`).
- `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on

View file

@ -5,7 +5,7 @@ sends — this is the only story that shows it. ``main`` posts that body by hand
asserts the response is a single ``application/json`` body with no session id.
"""
import httpx
import httpx2
from mcp_types import TextContent
from mcp_types.version import LATEST_MODERN_VERSION
@ -37,7 +37,7 @@ MODERN_HEADERS: dict[str, str] = {
}
async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None:
async def main(target: Target, *, mode: str = "auto", http: httpx2.AsyncClient) -> None:
async with Client(target, mode=mode) as client:
assert client.protocol_version == LATEST_MODERN_VERSION

View file

@ -92,7 +92,7 @@ eras need different auth, rate limits, or scaling.
- `ctx.request_context.protocol_version` is the interim 2-hop reach; a later
release will shorten it.
- DNS-rebinding protection is on by default; the harness disables it
(`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`.
(`NO_DNS_REBIND`) because the in-process httpx2 client sends no `Origin`.
Drop the kwarg for a real deployment.
- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned
before beta.

View file

@ -9,7 +9,7 @@ status = "current" # "current" | "legacy" | "deprec
lowlevel = true # also run main against server_lowlevel.build_server()/build_app()
server_export = "factory" # "factory" -> build_server() | "app" -> build_app()
multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call
needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only)
needs_http = false # main(..., http=) gets the raw httpx2.AsyncClient (http-asgi only)
timeout_s = 30
mcp_path = "/mcp"
fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port

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)

View file

@ -32,7 +32,7 @@ the client and server side.
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
client:` and that's the whole program. `target` is a transport that already
carries the OAuth `httpx.Auth`; the body never touches a token.
carries the OAuth `httpx2.Auth`; the body never touches a token.
- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider`
config is all the caller writes; the SDK does RFC 9728 PRM →
RFC 8414 AS-metadata discovery and token exchange on the first 401.
@ -50,13 +50,13 @@ the client and server side.
- `Client(url, auth=build_auth(http))` is the ergonomic the SDK is missing —
`Client(url)` has no `auth=` passthrough. Until it lands, the authed
`httpx.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has
`httpx2.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has
to be built *outside* `main` and handed in as `target`; both `run_client`
(the standalone `--http` run) and the test harness do that from the
`build_auth` export.
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by
default for localhost binds; the harness disables it because the in-process
httpx client sends no `Origin` header. Drop the kwarg for a real deployment.
httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment.
- `OAuthMetadata.authorization_endpoint` is a required field even though a
`client_credentials`-only AS has no authorize endpoint; the server sets a
dummy URL.

View file

@ -1,6 +1,6 @@
"""HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes."""
import httpx
import httpx2
from mcp.client import Client
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
@ -13,12 +13,12 @@ from stories._shared.auth import MCP_URL, InMemoryTokenStorage
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
"""The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config.
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config.
The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST →
Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the
harness threads this onto the transport's ``httpx.AsyncClient`` and hands ``main`` the
harness threads this onto the transport's ``httpx2.AsyncClient`` and hands ``main`` the
already-authed ``target``.
"""
return ClientCredentialsOAuthProvider(

View file

@ -57,7 +57,7 @@ kill "$SERVER_PID"
release; this story calls it directly because the event-store and
retry-interval kwargs are the point.
- DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`)
because the in-process httpx client sends no `Origin` header. Drop the kwarg
because the in-process httpx2 client sends no `Origin` header. Drop the kwarg
for a real deployment.
- `event_store.py` here is example-grade only (sequential IDs, no eviction). A
production server would back the `EventStore` interface with persistent

View file

@ -45,7 +45,7 @@ kill "$SERVER_PID"
## Caveats
- DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND`
because the in-process httpx client sends no `Origin` header. Drop the kwarg
because the in-process httpx2 client sends no `Origin` header. Drop the kwarg
for a real deployment.
- Neither `MCPServer` nor lowlevel `Server` auto-advertises
`resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob

View file

@ -41,7 +41,7 @@ kill "$SERVER_PID"
## Caveats
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
for localhost binds; the harness disables it because the in-process httpx
for localhost binds; the harness disables it because the in-process httpx2
client sends no `Origin` header. Drop the kwarg for a real deployment.
- `streamable_http_app()` reshapes in a later release; the call is isolated in
`build_app()` so the change touches one line per server file.