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

View file

@ -70,7 +70,7 @@ real-clock timeout tests (the timeout machinery is transport-independent and mus
transport latency), and everything under `transports/`, which pins behaviour only observable on
that transport.
A transport conformance test in `transports/` speaks raw `httpx` against the mounted ASGI app
A transport conformance test in `transports/` speaks raw `httpx2` against the mounted ASGI app
**only** when its assertion is about HTTP semantics that `Client` cannot observe — status codes,
response headers, SSE event fields, which stream a message travels on. Any other behaviour is
asserted through a `Client`, connected to the mounted app via `client_via_http(http)` so several

View file

@ -12,8 +12,8 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol
import httpx
from httpx_sse import ServerSentEvent, aconnect_sse
import httpx2
from httpx2 import ServerSentEvent
from mcp_types import (
ClientCapabilities,
Implementation,
@ -150,7 +150,7 @@ async def connect_over_streamable_http(
)
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
@ -182,17 +182,17 @@ async def mounted_app(
event_store: EventStore | None = None,
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
on_request: Callable[[httpx2.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx2.Response], Awaitable[None]] | None = None,
headers: dict[str, str] | None = None,
auth: AuthSettings | None = None,
token_verifier: TokenVerifier | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]:
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client.
) -> AsyncIterator[tuple[httpx2.AsyncClient, StreamableHTTPSessionManager]]:
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx2 client.
Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
Yields the httpx2 client (rooted at the in-process origin) and the live session manager. Tests
use this in two ways: for raw-httpx2 assertions (status codes, headers, SSE bytes) the test
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
session manager. `on_request` observes every outgoing HTTP request before it leaves the
@ -220,7 +220,7 @@ async def mounted_app(
event_hooks["response"] = [on_response]
async with (
server.session_manager.run(),
httpx.AsyncClient(
httpx2.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
) as http_client,
):
@ -229,7 +229,7 @@ async def mounted_app(
@asynccontextmanager
async def client_via_http(
http_client: httpx.AsyncClient,
http_client: httpx2.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
@ -238,8 +238,8 @@ async def client_via_http(
"""Connect a `Client` over an already-mounted streamable HTTP app.
Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
client-driven assertion can sit alongside raw-httpx assertions in the same test. The
underlying `httpx.AsyncClient` is left open when the `Client` exits.
client-driven assertion can sit alongside raw-httpx2 assertions in the same test. The
underlying `httpx2.AsyncClient` is left open when the `Client` exits.
"""
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
async with Client(
@ -260,22 +260,22 @@ def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage
async def post_jsonrpc(
http: httpx.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx.Response, list[JSONRPCMessage]]:
http: httpx2.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx2.Response, list[JSONRPCMessage]]:
"""POST a JSON-RPC body and read its SSE response stream to completion.
Returns the HTTP response (for header/status assertions) and the parsed JSON-RPC messages
that arrived on the response's SSE stream. Only meaningful for requests the server answers
with `text/event-stream`; for error responses or 202 notification acknowledgements, use
`httpx.AsyncClient.post` directly and assert on the response.
`httpx2.AsyncClient.post` directly and assert on the response.
"""
async with aconnect_sse(http, "POST", "/mcp", json=body, headers=base_headers(session_id=session_id)) as source:
events = [event async for event in source.aiter_sse()]
async with http.sse("/mcp", method="POST", json=body, headers=base_headers(session_id=session_id)) as source:
events = [event async for event in source]
return source.response, parse_sse_messages(events)
def base_headers(*, session_id: str | None = None) -> dict[str, str]:
"""Standard request headers for raw-httpx streamable-HTTP tests.
"""Standard request headers for raw-httpx2 streamable-HTTP tests.
Every well-formed request carries these (Accept covering both response representations,
Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
@ -304,16 +304,16 @@ def initialize_body(request_id: int = 1) -> dict[str, object]:
).model_dump(by_alias=True, exclude_none=True)
async def initialize_via_http(http: httpx.AsyncClient) -> str:
"""Perform the initialize handshake over a raw `httpx.AsyncClient` and return the session ID.
async def initialize_via_http(http: httpx2.AsyncClient) -> str:
"""Perform the initialize handshake over a raw `httpx2.AsyncClient` and return the session ID.
Validates the SSE response and sends the `notifications/initialized` follow-up, so the server
is fully ready for subsequent feature requests when this returns.
"""
async with aconnect_sse(http, "POST", "/mcp", json=initialize_body(), headers=base_headers()) as source:
async with http.sse("/mcp", method="POST", json=initialize_body(), headers=base_headers()) as source:
assert source.response.status_code == 200
# An event-store-backed server opens the stream with a priming event (empty data); skip it.
events = [event async for event in source.aiter_sse() if event.data]
events = [event async for event in source if event.data]
assert len(events) == 1
assert JSONRPCResponse.model_validate_json(events[0].data).id == 1
session_id = source.response.headers["mcp-session-id"]
@ -371,13 +371,13 @@ async def connect_over_sse(
def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
# The SSE server transport's connect_sse runs the entire MCP session inside the GET
# request and only releases its streams after that request observes a disconnect, so the
# bridge must let the application drain rather than cancelling at close.
return httpx.AsyncClient(
return httpx2.AsyncClient(
transport=StreamingASGITransport(app, cancel_on_close=False),
base_url=BASE_URL,
headers=headers,

View file

@ -17,7 +17,7 @@ assumptions about which side produced what.
from dataclasses import dataclass
import httpx
import httpx2
from mcp_types import JSONRPCMessage, jsonrpc_message_adapter
#: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy
@ -51,8 +51,8 @@ class RecordedExchange:
the server-to-client body content must be supplied via `frames`.
"""
requests: list[httpx.Request]
responses: list[httpx.Response]
requests: list[httpx2.Request]
responses: list[httpx2.Response]
frames: list[JSONRPCMessage]

View file

@ -3387,7 +3387,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"including auth flows."
),
transports=("streamable-http",),
note="Only observable over HTTP: the httpx client is HTTP-specific.",
note="Only observable over HTTP: the httpx2 client is HTTP-specific.",
),
"client-transport:http:custom-headers": Requirement(
source="sdk",

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"

View file

@ -5,7 +5,7 @@ the legacy initialize handshake, the modern `server/discover` probe, or nothing
that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta`
envelope on every subsequent request. Each test drives the highest public surface (`Client`)
and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and
`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport.
`mounted_app`'s httpx2 event hook for the in-process streamable-HTTP transport.
The fallback test alone hand-plays the server's side of the wire, because no real `Server`
answers `server/discover` with -32601.
@ -16,7 +16,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import anyio
import httpx
import httpx2
import mcp_types as types
import pytest
from mcp_types import (
@ -64,11 +64,11 @@ def _tools_server(name: str = "negotiator") -> Server:
return Server(name, on_list_tools=list_tools)
def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]:
"""Return a list and an `on_request` hook that appends each outgoing httpx request to it."""
captured: list[httpx.Request] = []
def _request_recorder() -> tuple[list[httpx2.Request], Callable[[httpx2.Request], Awaitable[None]]]:
"""Return a list and an `on_request` hook that appends each outgoing httpx2 request to it."""
captured: list[httpx2.Request] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
captured.append(request)
return captured, on_request
@ -100,7 +100,7 @@ async def test_pinned_mode_sends_no_connect_time_traffic() -> None:
Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a
synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross
the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook.
the wire. Asserted at the in-process streamable-HTTP seam via the httpx2 event hook.
"""
requests, on_request = _request_recorder()
@ -223,21 +223,21 @@ async def test_auto_mode_propagates_a_network_error_from_discover_without_initia
Requirement `lifecycle:discover:network-error-raises` (sdk-defined): under the denylist policy
every server-sent rpc-error and every transport-layer 4xx falls back to `initialize()`; the
only probe failures that reach the caller are real outages — network errors, anyio resource
errors, and the disjoint-modern -32022 case. Exercised here as an `httpx.ConnectError` from
errors, and the disjoint-modern -32022 case. Exercised here as an `httpx2.ConnectError` from
the underlying transport, which the policy must not classify as an era verdict. The error
reaches the test wrapped in the streamable-http transport's task-group teardown, so
`pytest.RaisesGroup` flattens before matching. The probe POST is recorded before the
transport raises, so the `initialize` fallback observably did not happen.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
raise httpx.ConnectError("connection refused")
raise httpx2.ConnectError("connection refused")
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.RaisesGroup(httpx.ConnectError, flatten_subgroups=True): # pragma: no branch
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
with pytest.RaisesGroup(httpx2.ConnectError, flatten_subgroups=True): # pragma: no branch
async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"):
raise NotImplementedError("entering the Client should have raised") # pragma: no cover

View file

@ -1,6 +1,6 @@
"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx.
"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx2.
`httpx.ASGITransport` runs the application to completion and only then hands the buffered
`httpx2.ASGITransport` runs the application to completion and only then hands the buffered
response to the caller, so a server that streams its response — the streamable HTTP transport's
SSE responses — can never converse with the client mid-request: a server-initiated request
nested inside a still-open call deadlocks. `StreamingASGITransport` removes that limitation by
@ -20,7 +20,7 @@ The behavioural contract, pinned by `test_bridge.py`:
server over a real socket would give.
The transport owns an anyio task group for the application tasks; it is opened and closed by
`httpx.AsyncClient`'s own context manager, so use the client as a context manager (the suite
`httpx2.AsyncClient`'s own context manager, so use the client as a context manager (the suite
always does). Closing the transport cancels every running application task by default; set
`cancel_on_close=False` to wait for the application's own disconnect handling instead.
"""
@ -31,14 +31,14 @@ from types import TracebackType
import anyio
import anyio.abc
import httpx
import httpx2
from anyio.streams.memory import MemoryObjectReceiveStream
from starlette.types import ASGIApp, Message, Scope
from mcp.shared._compat import resync_tracer
class _StreamingResponseBody(httpx.AsyncByteStream):
class _StreamingResponseBody(httpx2.AsyncByteStream):
"""A response body that yields chunks as the application produces them.
Closing it tells the application the client has gone away (`http.disconnect`), mirroring a
@ -58,7 +58,7 @@ class _StreamingResponseBody(httpx.AsyncByteStream):
await self._chunks.aclose()
class StreamingASGITransport(httpx.AsyncBaseTransport):
class StreamingASGITransport(httpx2.AsyncBaseTransport):
"""Drive an ASGI application in-process, streaming each response as it is produced.
With `cancel_on_close` (the default), closing the transport cancels every application task
@ -84,7 +84,7 @@ class StreamingASGITransport(httpx.AsyncBaseTransport):
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
) -> None:
# httpx closes every streamed response before closing the transport, so by now each
# httpx2 closes every streamed response before closing the transport, so by now each
# application task has been delivered `http.disconnect`. Either cancel immediately, or wait
# for the application's own disconnect handling to unwind.
if self._cancel_on_close:
@ -92,8 +92,8 @@ class StreamingASGITransport(httpx.AsyncBaseTransport):
await self._task_group.__aexit__(exc_type, exc_value, traceback)
await resync_tracer()
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
assert isinstance(request.stream, httpx.AsyncByteStream)
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
assert isinstance(request.stream, httpx2.AsyncByteStream)
request_body = b"".join([chunk async for chunk in request.stream])
scope: Scope = {
@ -164,7 +164,7 @@ class StreamingASGITransport(httpx.AsyncBaseTransport):
client_disconnected.set()
await chunk_reader.aclose()
raise
return httpx.Response(
return httpx2.Response(
status_code=response_status,
headers=response_headers,
stream=_StreamingResponseBody(chunk_reader, client_disconnected),

View file

@ -8,7 +8,7 @@ contract in `test_coverage.py`.
"""
import anyio
import httpx
import httpx2
import pytest
from starlette.types import Message, Receive, Scope, Send
@ -29,7 +29,7 @@ async def test_response_chunks_arrive_as_the_application_sends_them() -> None:
await send({"type": "http.response.body", "body": b"second", "more_body": False})
async with (
httpx.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http,
httpx2.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http,
http.stream("GET", "/chunks") as response,
):
with anyio.fail_after(5):
@ -52,7 +52,7 @@ async def test_closing_the_response_delivers_a_disconnect_to_the_application() -
seen_after_request.append(await receive())
disconnect_seen.set()
async with httpx.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http:
async with httpx2.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http:
async with http.stream("GET", "/wait") as response:
assert response.status_code == 200
# Leaving the stream block closes the response while the application is still mid-response.
@ -68,7 +68,7 @@ async def test_an_application_failure_before_the_response_starts_fails_the_reque
async def broken_app(scope: Scope, receive: Receive, send: Send) -> None:
raise RuntimeError("the demo application is broken")
async with httpx.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http:
async with httpx2.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http:
with pytest.raises(RuntimeError, match="the demo application is broken"):
await http.get("/broken")
@ -87,7 +87,7 @@ async def test_disabling_cancel_on_close_lets_the_application_finish_after_disco
transport = StreamingASGITransport(lingering_app, cancel_on_close=False)
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
async with httpx2.AsyncClient(transport=transport, base_url="http://bridge") as http:
async with http.stream("GET", "/linger") as response:
assert response.status_code == 200
assert not cleanup_ran.is_set()

View file

@ -10,7 +10,7 @@ import json
from collections.abc import AsyncIterator
import anyio
import httpx
import httpx2
import mcp_types as types
import pytest
from inline_snapshot import snapshot
@ -44,16 +44,16 @@ def _tooled_server() -> Server:
@pytest.fixture
async def recorded() -> AsyncIterator[list[httpx.Request]]:
async def recorded() -> AsyncIterator[list[httpx2.Request]]:
"""Connect a `Client` over a recording HTTP client, list tools, exit, and yield every request sent.
The HTTP client carries one caller-supplied header (`x-trace`) so its propagation can be
asserted; the recording captures the closing DELETE because it is read after the `Client` has
fully exited.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
requests.append(request)
async with mounted_app(_tooled_server(), on_request=record, headers={"x-trace": "abc"}) as (http, _):
@ -64,7 +64,7 @@ async def recorded() -> AsyncIterator[list[httpx.Request]]:
yield requests
def _after_initialize(recorded: list[httpx.Request]) -> list[httpx.Request]:
def _after_initialize(recorded: list[httpx2.Request]) -> list[httpx2.Request]:
"""Every recorded request after the initialize POST (which carries no session yet)."""
assert recorded[0].method == "POST"
assert "mcp-session-id" not in recorded[0].headers
@ -74,9 +74,9 @@ def _after_initialize(recorded: list[httpx.Request]) -> list[httpx.Request]:
@requirement("client-transport:http:custom-client")
@requirement("client-transport:http:custom-headers")
async def test_the_client_uses_the_supplied_http_client_and_propagates_its_headers(
recorded: list[httpx.Request],
recorded: list[httpx2.Request],
) -> None:
"""A caller-supplied `httpx.AsyncClient` is used for every request and carries its own headers.
"""A caller-supplied `httpx2.AsyncClient` is used for every request and carries its own headers.
The recording itself proves the supplied client is the one in use; the propagated header
proves the SDK transport does not replace the caller's client configuration.
@ -88,7 +88,7 @@ async def test_the_client_uses_the_supplied_http_client_and_propagates_its_heade
@requirement("client-transport:http:session-stored")
async def test_every_request_after_initialize_carries_the_issued_session_id(recorded: list[httpx.Request]) -> None:
async def test_every_request_after_initialize_carries_the_issued_session_id(recorded: list[httpx2.Request]) -> None:
"""The session id from the initialize response is sent on every subsequent request."""
session_ids = {request.headers["mcp-session-id"] for request in _after_initialize(recorded)}
assert len(session_ids) == 1
@ -99,7 +99,7 @@ async def test_every_request_after_initialize_carries_the_issued_session_id(reco
@requirement("client-transport:http:protocol-version-stored")
@requirement("client-transport:http:protocol-version-header")
async def test_every_request_after_initialize_carries_the_negotiated_protocol_version(
recorded: list[httpx.Request],
recorded: list[httpx2.Request],
) -> None:
"""The negotiated protocol version is sent on every subsequent request (and not on initialize)."""
assert "mcp-protocol-version" not in recorded[0].headers
@ -110,7 +110,7 @@ async def test_every_request_after_initialize_carries_the_negotiated_protocol_ve
@requirement("client-transport:http:accept-header-post")
@requirement("client-transport:http:accept-header-get")
async def test_accept_headers_cover_the_response_representations_the_transport_handles(
recorded: list[httpx.Request],
recorded: list[httpx2.Request],
) -> None:
"""POSTs accept both JSON and SSE; the standalone GET stream accepts SSE."""
for request in recorded:
@ -122,7 +122,7 @@ async def test_accept_headers_cover_the_response_representations_the_transport_h
@requirement("client-transport:http:no-reconnect-after-close")
async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: list[httpx.Request]) -> None:
async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: list[httpx2.Request]) -> None:
"""Client teardown sends DELETE and issues no further requests (no resumption GET)."""
assert recorded[-1].method == "DELETE"
assert all("last-event-id" not in request.headers for request in recorded)
@ -131,10 +131,10 @@ async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded:
@requirement("client-transport:http:concurrent-streams")
async def test_concurrent_tool_calls_each_open_a_post_stream_and_receive_their_own_response() -> None:
"""Three tool calls issued at once each open their own POST stream and get the right answer."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
results: dict[int, CallToolResult] = {}
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
requests.append(request)
async with mounted_app(_tooled_server(), on_request=record) as (http, _), client_via_http(http) as client:
@ -179,7 +179,7 @@ async def test_client_tolerates_405_on_get_and_delete() -> None:
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(filter_methods), base_url=BASE_URL) as http_client,
httpx2.AsyncClient(transport=StreamingASGITransport(filter_methods), base_url=BASE_URL) as http_client,
):
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
with anyio.fail_after(5): # pragma: no branch
@ -197,9 +197,9 @@ async def test_a_completed_post_stream_is_not_reconnected() -> None:
Last-Event-ID it could resume from -- the test proves it does not, because the response arrived
and the stream completed normally.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
requests.append(request)
server = _tooled_server()
@ -238,7 +238,7 @@ async def test_a_404_mid_session_surfaces_as_a_session_terminated_error() -> Non
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client,
httpx2.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client,
):
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
with anyio.fail_after(5): # pragma: no branch
@ -281,7 +281,7 @@ async def test_at_2026_abandoning_a_call_closes_its_stream_and_posts_nothing() -
handler_cancelled = anyio.Event()
requests: list[tuple[str, bytes]] = []
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
requests.append((request.method, request.content))
server = _blocking_server(handler_started, handler_cancelled)
@ -321,7 +321,7 @@ async def test_at_2025_abandoning_a_call_posts_exactly_one_cancelled_frame() ->
handler_cancelled = anyio.Event()
requests: list[tuple[str, bytes]] = []
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
requests.append((request.method, request.content))
server = _blocking_server(handler_started, handler_cancelled)

View file

@ -6,7 +6,7 @@ running over the matrix.
"""
import anyio
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, LoggingMessageNotificationParams, TextContent
@ -76,7 +76,7 @@ async def test_a_fresh_connection_after_termination_obtains_a_new_session_and_op
session_ids: list[str] = []
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
session_id = request.headers.get("mcp-session-id")
if session_id is not None:
session_ids.append(session_id)

View file

@ -9,7 +9,7 @@ travels on -- that the SDK client never exposes. Transport-agnostic behaviour is
import anyio
import pytest
from anyio.lowlevel import checkpoint
from httpx_sse import ServerSentEvent, aconnect_sse
from httpx2 import ServerSentEvent
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
@ -260,7 +260,7 @@ async def test_a_second_standalone_get_stream_on_the_same_session_returns_409()
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as first:
async with http.sse("/mcp", headers=base_headers(session_id=session_id)) as first:
assert first.response.status_code == 200
# The standalone-stream writer registers its key as its first action, then parks
# awaiting messages; one yield to the loop lets that registration complete before the
@ -296,10 +296,10 @@ async def test_messages_are_routed_to_exactly_one_stream() -> None:
get_events: list[ServerSentEvent] = []
async def read_standalone_stream() -> None:
async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as get:
async with http.sse("/mcp", headers=base_headers(session_id=session_id)) as get:
assert get.response.status_code == 200
standalone_ready.set()
async for event in get.aiter_sse():
async for event in get:
get_events.append(event)
seen_on_standalone.set()
@ -312,16 +312,15 @@ async def test_messages_are_routed_to_exactly_one_stream() -> None:
params = CallToolRequestParams(name="narrate", arguments={})
body = JSONRPCRequest(jsonrpc="2.0", id=5, method="tools/call", params=params.model_dump())
async with aconnect_sse(
http,
"POST",
async with http.sse(
"/mcp",
method="POST",
json=body.model_dump(by_alias=True, exclude_none=True),
headers=base_headers(session_id=session_id),
) as post:
assert post.response.status_code == 200
# The POST stream iterator ends when the server closes the stream after the response.
post_events = [event async for event in post.aiter_sse()]
post_events = [event async for event in post]
await seen_on_standalone.wait()
tg.cancel_scope.cancel()
@ -360,11 +359,14 @@ async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> No
"/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
)
bad_host = await http.post("/mcp", json=initialize_body(), headers=base_headers() | {"host": "evil.example"})
async with aconnect_sse(
http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://127.0.0.1:8000"}
async with http.sse(
"/mcp",
method="POST",
json=initialize_body(),
headers=base_headers() | {"origin": "http://127.0.0.1:8000"},
) as ok:
assert ok.response.status_code == 200
assert [event async for event in ok.aiter_sse()]
assert [event async for event in ok]
assert (bad_origin.status_code, bad_origin.text) == snapshot((403, "Invalid Origin header"))
assert (bad_host.status_code, bad_host.text) == snapshot((421, "Invalid Host header"))
@ -372,10 +374,10 @@ async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> No
async with mounted_app(
Server("unguarded"), transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
) as (http, _):
async with aconnect_sse(
http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
async with http.sse(
"/mcp", method="POST", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
) as unguarded:
status = unguarded.response.status_code
assert [event async for event in unguarded.aiter_sse()]
assert [event async for event in unguarded]
assert status == 200

View file

@ -12,7 +12,7 @@ from collections.abc import Callable
from typing import Any, Literal
import anyio
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@ -77,7 +77,7 @@ def _meta_envelope() -> dict[str, object]:
def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server:
"""A low-level server with one ``add`` tool for the raw-httpx tests below."""
"""A low-level server with one ``add`` tool for the raw-httpx2 tests below."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
tool = Tool(name="add", input_schema={"type": "object"})
@ -311,7 +311,7 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern
plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. The caller passes a
``custom-key`` under ``meta=`` and the server handler captures the incoming ``ctx.meta``,
proving the envelope merge is additive: the caller's key sits alongside the three envelope keys
on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx event
on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx2 event
hooks because none of the headers, the envelope, or the handshake-absence is observable through
the public client API. The recorded log shows two POSTs: the ``tools/call`` itself and the
client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``),
@ -320,13 +320,13 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern
observed_metas: list[dict[str, Any]] = []
server = _server(on_meta=observed_metas.append)
requests: list[httpx.Request] = []
responses: list[httpx.Response] = []
requests: list[httpx2.Request] = []
responses: list[httpx2.Response] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
requests.append(request)
async def on_response(response: httpx.Response) -> None:
async def on_response(response: httpx2.Response) -> None:
responses.append(response)
client_info = Implementation(name="e2e-client", version="1.0.0")
@ -432,9 +432,9 @@ async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers()
`verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted
at the wire because the client never surfaces the outgoing headers.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
requests.append(request)
discover = DiscoverResult(
@ -479,9 +479,9 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No
The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table
requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet).
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
requests.append(request)
discover = DiscoverResult(
@ -532,9 +532,9 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool)
tool_calls: list[httpx.Request] = []
tool_calls: list[httpx2.Request] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
tool_calls.append(request)
@ -588,9 +588,9 @@ async def test_vendor_request_with_name_param_carries_mcp_name_on_the_wire() ->
server = _server()
server.add_request_handler("com.example/jobs.status", _JobParams, job_status)
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
requests.append(request)
discover = DiscoverResult(

View file

@ -2,7 +2,7 @@
These tests configure the server with an event store, so every SSE event is stamped with an ID
and a client that loses its connection can resume by sending `Last-Event-ID`. The wire-level
tests (`mounted_app` + raw httpx) assert exactly what travels on the wire; the end-to-end test
tests (`mounted_app` + raw httpx2) assert exactly what travels on the wire; the end-to-end test
drives the SDK client through a server-initiated stream close and proves the call still
completes. The bridge's `aclose()` delivers `http.disconnect` to the running application, so
closing a streaming response mid-read is a deterministic in-process disconnect -- no sockets,
@ -12,9 +12,9 @@ no real time. Every server here uses `retry_interval=0` so reconnection waits ar
import json
import anyio
import httpx
import httpx2
import pytest
from httpx_sse import EventSource, ServerSentEvent
from httpx2 import EventSource, ServerSentEvent
from inline_snapshot import snapshot
from mcp_types import (
CallToolRequest,
@ -69,9 +69,9 @@ def _tools_call(request_id: int, name: str, arguments: dict[str, object]) -> str
).model_dump_json(by_alias=True, exclude_none=True)
async def _read_events(response: httpx.Response, count: int) -> list[ServerSentEvent]:
async def _read_events(response: httpx2.Response, count: int) -> list[ServerSentEvent]:
"""Read exactly `count` SSE events from a streaming response without closing it."""
source = EventSource(response).aiter_sse()
source = aiter(EventSource(response))
return [await anext(source) for _ in range(count)]
@ -273,7 +273,7 @@ async def test_an_unknown_last_event_id_yields_an_empty_replay_stream() -> None:
async with http.stream("GET", "/mcp", headers=headers) as replay:
assert replay.status_code == 200
assert replay.headers["content-type"].startswith("text/event-stream")
events = [event async for event in EventSource(replay).aiter_sse()]
events = [event async for event in EventSource(replay)]
assert events == []

View file

@ -9,7 +9,7 @@ behaviour is covered by the `connect`-fixture matrix.
import re
import anyio
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
@ -167,9 +167,9 @@ async def test_stateless_mode_never_issues_a_session_id() -> None:
cannot have issued one, or the client would echo it); the empty instance map proves the
manager kept no transport between requests.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
async def record(request: httpx.Request) -> None:
async def record(request: httpx2.Request) -> None:
requests.append(request)
async with mounted_app(_server(), stateless_http=True, on_request=record) as (http, manager):

View file

@ -1,13 +1,13 @@
"""Legacy-wire protection: a 2025-era streamable-HTTP exchange stays free of 2026 vocabulary.
Records a full SDK client -> SDK server round trip at both seams (HTTP request/response headers
via httpx event hooks; JSON-RPC frames in both directions via the recording transport) and runs
via httpx2 event hooks; JSON-RPC frames in both directions via the recording transport) and runs
the result through :func:`tests.interaction._modern_vocab.assert_no_modern_vocabulary`. The test
pins today's wire so any future 2026-07-28 work that leaks new fields, `_meta` keys, or headers
onto a connection negotiated at the current protocol version fails here.
"""
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@ -58,10 +58,10 @@ async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabu
"""
recorded = RecordedExchange(requests=[], responses=[], frames=[])
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
recorded.requests.append(request)
async def on_response(response: httpx.Response) -> None:
async def on_response(response: httpx2.Response) -> None:
recorded.responses.append(response)
async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _):

View file

@ -10,7 +10,7 @@ through the suite's streaming ASGI bridge.
from uuid import UUID, uuid4
import anyio
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import EmptyResult
@ -36,10 +36,10 @@ async def test_endpoint_event_names_the_message_endpoint_with_a_fresh_session_id
def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
return httpx2.AsyncClient(
transport=StreamingASGITransport(app, cancel_on_close=False),
base_url=BASE_URL,
headers=headers,
@ -63,7 +63,7 @@ async def test_endpoint_event_names_the_message_endpoint_with_a_fresh_session_id
async def test_post_without_a_session_id_is_rejected() -> None:
"""A POST to the message endpoint with no session_id query parameter is answered 400."""
app, _ = build_sse_app(Server("legacy"))
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:
response = await http.post("/messages/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert (response.status_code, response.text) == snapshot((400, "session_id is required"))
@ -72,7 +72,7 @@ async def test_post_without_a_session_id_is_rejected() -> None:
async def test_post_with_a_malformed_session_id_is_rejected() -> None:
"""A POST whose session_id query parameter is not a UUID is answered 400."""
app, _ = build_sse_app(Server("legacy"))
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:
response = await http.post(
"/messages/", params={"session_id": "not-a-uuid"}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
)
@ -83,7 +83,7 @@ async def test_post_with_a_malformed_session_id_is_rejected() -> None:
async def test_post_for_an_unknown_session_is_rejected() -> None:
"""A POST naming a well-formed session_id that no SSE stream owns is answered 404."""
app, _ = build_sse_app(Server("legacy"))
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:
response = await http.post(
"/messages/", params={"session_id": uuid4().hex}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
)