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

View file

@ -3,7 +3,7 @@
Co-hosts the SDK's authorization-server routes, protected-resource metadata route, and the
bearer-gated MCP endpoint on one Starlette app via `Server.streamable_http_app(auth=...,
token_verifier=..., auth_server_provider=...)`, drives that app through the streaming bridge
on a single `httpx.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
on a single `httpx2.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
authorize redirect headlessly by GETing the URL through the same bridge and parsing the code
from the 302 `Location`. The whole authorization-code flow runs in one event loop with no
sockets, no threads, and no real time.
@ -16,7 +16,7 @@ from dataclasses import dataclass, field
from typing import Any
from urllib.parse import parse_qs, parse_qsl, urlsplit
import httpx
import httpx2
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
from starlette.types import ASGIApp, Receive, Scope, Send
@ -38,15 +38,15 @@ AppShim = Callable[[ASGIApp], ASGIApp]
@dataclass
class RecordedRequest:
"""A snapshot of an `httpx.Request` at the moment it was sent.
"""A snapshot of an `httpx2.Request` at the moment it was sent.
The auth flow re-yields the same `httpx.Request` object after mutating its headers in
The auth flow re-yields the same `httpx2.Request` object after mutating its headers in
place for the retry, so tests that need to assert on the first attempt's headers must
capture a copy rather than a live reference. `record_requests` produces these.
"""
method: str
url: httpx.URL
url: httpx2.URL
headers: dict[str, str]
content: bytes
@ -55,11 +55,11 @@ class RecordedRequest:
return self.url.path
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx.Request], None]]:
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx2.Request], None]]:
"""Build an `on_request` callback that snapshots each request, and the list it appends to."""
recorded: list[RecordedRequest] = []
def on_request(request: httpx.Request) -> None:
def on_request(request: httpx2.Request) -> None:
recorded.append(
RecordedRequest(
method=request.method,
@ -147,12 +147,12 @@ class HeadlessOAuth:
self.error: str | None = None
self._state_override = state_override
self._iss_override = iss_override
self._http: httpx.AsyncClient | None = None
self._http: httpx2.AsyncClient | None = None
self._code: str = ""
self._state: str | None = None
self._iss: str | None = 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:
@ -411,16 +411,16 @@ async def connect_with_oauth(
client_metadata: OAuthClientMetadata | None = None,
client_metadata_url: str | None = None,
headless: HeadlessOAuth | None = None,
auth: httpx.Auth | None = None,
auth: httpx2.Auth | None = None,
verify_tokens: bool = True,
app_shim: Callable[[ASGIApp], ASGIApp] | None = None,
on_request: Callable[[httpx.Request], None] | None = None,
on_request: Callable[[httpx2.Request], None] | None = None,
) -> AsyncIterator[tuple[Client, HeadlessOAuth]]:
"""Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process.
Yields the connected `Client` and the `HeadlessOAuth` whose `authorize_url` records what the
SDK put on the authorize request. `on_request` records every HTTP request the underlying
`httpx.AsyncClient` issues, including those yielded from inside the auth flow.
`httpx2.AsyncClient` issues, including those yielded from inside the auth flow.
`headless`: supply a pre-configured `HeadlessOAuth` to override the callback behaviour
(state mismatch, error redirects). `verify_tokens=False` mounts the MCP endpoint without
@ -428,7 +428,7 @@ async def connect_with_oauth(
scopes. `app_shim` wraps the built Starlette app before it reaches the bridge transport,
for tests that need to intercept or rewrite specific server responses.
`auth`: supply a pre-built `httpx.Auth` (such as `ClientCredentialsOAuthProvider`) to use
`auth`: supply a pre-built `httpx2.Auth` (such as `ClientCredentialsOAuthProvider`) to use
instead of constructing the default `OAuthClientProvider`; in that case `storage`,
`client_metadata`, `client_metadata_url`, and `headless` are unused (the yielded
`HeadlessOAuth` is never invoked and its `authorize_url` stays None).
@ -464,7 +464,7 @@ async def connect_with_oauth(
if on_request is not None:
record = on_request
async def hook(request: httpx.Request) -> None:
async def hook(request: httpx2.Request) -> None:
record(request)
event_hooks = {"request": [hook]}
@ -472,7 +472,7 @@ async def connect_with_oauth(
async with AsyncExitStack() as stack:
await stack.enter_async_context(server.session_manager.run())
http_client = await stack.enter_async_context(
httpx.AsyncClient(
httpx2.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
)
)

View file

@ -1,7 +1,7 @@
"""Error-plane behaviour of the SDK's bundled OAuth authorization-server handlers.
The end-to-end OAuth tests prove the handlers' happy paths; these tests drive the same
mounted authorization server directly with raw httpx so the assertions are the HTTP
mounted authorization server directly with raw httpx2 so the assertions are the HTTP
semantics (status, redirect target, error body, headers) the OAuth RFCs mandate. Almost
every behaviour here is enforced by the SDK's own handlers; where the pinned output
deviates from the RFC, the manifest entry carries the divergence.
@ -13,7 +13,7 @@ import secrets
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
@ -29,8 +29,8 @@ pytestmark = pytest.mark.anyio
@pytest.fixture
async def as_app() -> AsyncIterator[tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider]]:
"""Co-host the SDK's authorization-server routes and yield a raw httpx client against them."""
async def as_app() -> AsyncIterator[tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider]]:
"""Co-host the SDK's authorization-server routes and yield a raw httpx2 client against them."""
provider = InMemoryAuthorizationServerProvider()
settings = auth_settings()
async with mounted_app(
@ -49,14 +49,14 @@ def _pkce_pair() -> tuple[str, str]:
return verifier, challenge
async def _register_client(http: httpx.AsyncClient) -> OAuthClientInformationFull:
async def _register_client(http: httpx2.AsyncClient) -> OAuthClientInformationFull:
"""Dynamically register a client and return its full credentials."""
response = await http.post("/register", content=oauth_client_metadata().model_dump_json())
assert response.status_code == 201
return OAuthClientInformationFull.model_validate_json(response.content)
async def _mint_code(http: httpx.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
async def _mint_code(http: httpx2.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
"""Register a client, complete a valid authorize step, and return (client_info, code, verifier)."""
client_info = await _register_client(http)
assert client_info.client_id is not None
@ -96,7 +96,7 @@ def _token_form(client_info: OAuthClientInformationFull, **overrides: str) -> di
@requirement("hosting:auth:as:authorize-requires-pkce")
async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_request(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request omitting `code_challenge` is redirected back with `error=invalid_request`.
@ -131,7 +131,7 @@ async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_reque
@requirement("hosting:auth:as:verifier-mismatch")
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `code_verifier` does not hash to the stored challenge is rejected."""
http, _ = as_app
@ -145,7 +145,7 @@ async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
@requirement("hosting:auth:as:code-single-use")
async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorization code can be exchanged exactly once; a second exchange is `invalid_grant`.
@ -171,7 +171,7 @@ async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
@ -200,7 +200,7 @@ async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_
@requirement("hosting:auth:as:token-cache-headers")
async def test_token_responses_carry_cache_control_no_store(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Every token-endpoint response (success and error) carries `Cache-Control: no-store`."""
http, _ = as_app
@ -220,7 +220,7 @@ async def test_token_responses_carry_cache_control_no_store(
@requirement("hosting:auth:as:register-error-response")
async def test_registration_with_invalid_metadata_is_rejected_with_400(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Invalid client metadata at the registration endpoint returns 400 with an RFC 7591 error body."""
http, _ = as_app
@ -247,7 +247,7 @@ async def test_registration_with_invalid_metadata_is_rejected_with_400(
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request naming an unregistered `redirect_uri` returns 400 without redirecting to it.
@ -280,7 +280,7 @@ async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
@requirement("hosting:auth:as:redirect-uri-scheme")
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.

View file

@ -10,7 +10,7 @@ endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flag
import time
from collections.abc import AsyncIterator
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse
@ -44,7 +44,7 @@ TOKENS = {
@pytest.fixture
async def protected() -> AsyncIterator[httpx.AsyncClient]:
async def protected() -> AsyncIterator[httpx2.AsyncClient]:
"""A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge."""
server = Server("rs")
settings = auth_settings(required_scopes=[REQUIRED_SCOPE])
@ -53,8 +53,8 @@ async def protected() -> AsyncIterator[httpx.AsyncClient]:
async def post_mcp(
http: httpx.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
) -> httpx.Response:
http: httpx2.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
) -> httpx2.Response:
"""POST an initialize body to `/mcp`, optionally with a bearer token and/or a query string."""
headers = base_headers()
if bearer is not None:
@ -76,7 +76,7 @@ def parse_www_authenticate(value: str) -> dict[str, str]:
@requirement("hosting:auth:missing-401")
async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata(
protected: httpx.AsyncClient,
protected: httpx2.AsyncClient,
) -> None:
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
@ -103,7 +103,7 @@ async def test_a_request_with_no_authorization_header_is_challenged_with_resourc
@requirement("hosting:auth:invalid-401")
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx.AsyncClient) -> None:
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx2.AsyncClient) -> None:
"""A token the verifier does not recognize is answered 401 `invalid_token`.
The challenge is identical to the no-header case (the backend returns `None` for both); the
@ -120,7 +120,7 @@ async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protec
@requirement("hosting:auth:expired-401")
async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) -> None:
async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) -> None:
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.
The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
@ -135,7 +135,7 @@ async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) ->
@requirement("hosting:auth:scope-403")
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
protected: httpx.AsyncClient,
protected: httpx2.AsyncClient,
) -> None:
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
@ -157,7 +157,7 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco
@requirement("hosting:auth:aud-validation")
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None:
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None:
"""A token whose `resource` does not match the server's resource identifier is accepted.
The spec mandates the resource server validate the token's audience; the bearer backend
@ -175,7 +175,7 @@ async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.A
@requirement("hosting:auth:query-token-ignored")
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx.AsyncClient) -> None:
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx2.AsyncClient) -> None:
"""A valid token presented in the URI query string is treated as no authentication.
The bearer backend reads only the `Authorization` header, so `?access_token=...` is never

View file

@ -6,7 +6,7 @@ detail `Client` cannot observe directly but the recording can. Tests that need a
endpoint to 404 or return alternate content wrap the SDK's app in `shimmed_app` while leaving
the real authorize and token endpoints behind it, so the rest of the flow runs unaltered.
The two server-side tests (#5, #6) drive raw httpx against `mounted_app` because their
The two server-side tests (#5, #6) drive raw httpx2 against `mounted_app` because their
assertions are the metadata response bodies and headers, which `Client` does not surface.
"""

View file

@ -3,7 +3,7 @@
Auth is HTTP-only so these tests are not transport-parametrized; each connects via
`connect_with_oauth`, which co-hosts the SDK's authorization server, protected-resource
metadata, and bearer-gated MCP endpoint on one bridge-backed Starlette app and drives the
whole flow through one `httpx.AsyncClient` carrying the SDK's `OAuthClientProvider`. The
whole flow through one `httpx2.AsyncClient` carrying the SDK's `OAuthClientProvider`. The
authorize redirect completes headlessly through the same bridge, so every request the flow
makes is observable via `on_request`.
"""
@ -13,7 +13,7 @@ from collections import Counter
from urllib.parse import parse_qs, urlsplit
import anyio
import httpx
import httpx2
import mcp_types as types
import pytest
from inline_snapshot import snapshot
@ -64,7 +64,7 @@ async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow
6. POST /token (authorization-code exchange).
7. Retry POST /mcp with `Authorization: Bearer <access_token>` → succeeds.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
@ -145,7 +145,7 @@ async def test_a_preregistered_client_skips_registration() -> None:
The provider holds the same registration server-side so the authorize and token steps
accept it; the recorded requests prove no `/register` call was made.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
@ -180,7 +180,7 @@ async def test_the_dcr_request_carries_the_client_metadata() -> None:
scope filled in from server discovery), and the server's issued client_id and secret are
persisted to storage and held by the provider.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
@ -227,7 +227,7 @@ async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_w
real_app = server.streamable_http_app(auth=auth_settings(), auth_server_provider=provider)
app = shimmed_app(real_app, not_found=frozenset({"/missing"}), serve={"/override": b'{"shimmed": true}'})
async with server.session_manager.run():
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
served = await http.get("/override")
assert served.status_code == 200
assert served.headers["content-type"] == "application/json"