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

View file

@ -2,7 +2,7 @@
import inspect
import httpx
import httpx2
import pytest
from mcp_types import TextContent
from starlette.applications import Starlette
@ -50,8 +50,8 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
"""The `!!! check`: nothing starts the session manager except its lifespan."""
transport = httpx.ASGITransport(app=tutorial001.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=tutorial001.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
@ -76,12 +76,12 @@ async def test_a_root_mount_swallows_routes_listed_after_it() -> None:
listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)])
listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)])
transport = httpx.ASGITransport(app=listed_after)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=listed_after)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 404
transport = httpx.ASGITransport(app=listed_before)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=listed_before)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 200
@ -127,8 +127,8 @@ async def test_cors_exposes_the_session_id_header() -> None:
"""tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`."""
(middleware,) = tutorial005.app.user_middleware
assert middleware.cls is CORSMiddleware
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=tutorial005.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
preflight = await http.options(
"/mcp",
headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"},
@ -152,8 +152,8 @@ async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None:
async def test_the_health_check_answers_outside_the_protocol() -> None:
"""tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP."""
transport = httpx.ASGITransport(app=tutorial006.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=tutorial006.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
response = await http.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@ -174,11 +174,11 @@ async def test_the_default_app_is_localhost_only() -> None:
before any MCP code runs."""
bare = MCPServer("Notes")
app = bare.streamable_http_app()
transport = httpx.ASGITransport(app=app)
transport = httpx2.ASGITransport(app=app)
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
wrong_origin = await http.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
)
@ -189,9 +189,9 @@ async def test_the_default_app_is_localhost_only() -> None:
async def test_the_documented_browser_origin_works_end_to_end() -> None:
"""tutorial005: the page's scenario for real. The public hostname, the browser origin, a
realistic preflight naming the `Mcp-*` headers, then the actual request."""
transport = httpx.ASGITransport(app=tutorial005.app)
transport = httpx2.ASGITransport(app=tutorial005.app)
async with tutorial005.lifespan(tutorial005.app):
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
preflight = await http.options(
"/mcp",
headers={

View file

@ -1,6 +1,6 @@
"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK."""
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent
@ -40,8 +40,8 @@ async def test_the_app_grows_a_protected_resource_metadata_route() -> None:
async def test_the_metadata_document_is_built_from_auth_settings() -> None:
"""tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert response.json() == snapshot(
@ -56,8 +56,8 @@ async def test_the_metadata_document_is_built_from_auth_settings() -> None:
async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
"""The `!!! check`: no `Authorization` header means a 401 that points at the metadata document."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.post("/mcp", json={})
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
@ -69,8 +69,8 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None:
"""tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"})
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
@ -86,11 +86,11 @@ async def test_get_access_token_is_none_outside_an_authenticated_request() -> No
async def test_get_access_token_is_the_callers_access_token() -> None:
"""tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`."""
url = "http://127.0.0.1:8000/mcp"
transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app())
transport = httpx2.ASGITransport(app=tutorial002.mcp.streamable_http_app())
headers = {"Authorization": "Bearer alice-token"}
async with tutorial002.mcp.session_manager.run():
async with (
httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client,
httpx2.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client,
Client(streamable_http_client(url, http_client=http_client)) as client,
):
result = await client.call_tool("whoami", {})

View file

@ -1,7 +1,7 @@
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
import anyio
import httpx
import httpx2
import pytest
from mcp_types import (
INVALID_PARAMS,
@ -44,18 +44,18 @@ async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None
bare = MCPServer("Notes")
app = bare.streamable_http_app()
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="https://api.example.com") as h:
response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
transport = httpx.ASGITransport(app=tutorial001.app)
transport = httpx2.ASGITransport(app=tutorial001.app)
async with tutorial001.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert allowed.status_code == 200
assert allowed.headers["mcp-session-id"]

View file

@ -3,7 +3,7 @@
import inspect
from urllib.parse import parse_qsl
import httpx
import httpx2
import jwt
import pytest
from inline_snapshot import snapshot
@ -27,21 +27,21 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDepre
MCP_SERVER_URL = "http://localhost:8001/mcp"
class RecordingASGITransport(httpx.ASGITransport):
"""An `httpx.ASGITransport` that appends every (method, path, body) it carries to a shared log."""
class RecordingASGITransport(httpx2.ASGITransport):
"""An `httpx2.ASGITransport` that appends every (method, path, body) it carries to a shared log."""
def __init__(self, app: Starlette, log: list[tuple[str, str, bytes]]) -> None:
super().__init__(app=app)
self.log = log
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
self.log.append((request.method, request.url.path, request.content))
return await super().handle_async_request(request)
async def test_the_provider_is_an_httpx_auth_but_not_an_oauth_client_provider() -> None:
"""tutorial001: same `auth=` slot as the rest of OAuth clients, but nothing is discovered or registered."""
assert isinstance(tutorial001.oauth, httpx.Auth)
assert isinstance(tutorial001.oauth, httpx2.Auth)
assert not isinstance(tutorial001.oauth, OAuthClientProvider)
@ -132,8 +132,8 @@ async def test_a_replayed_assertion_is_rejected() -> None:
async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -> None:
"""tutorial002: the flag turns on both the `jwt-bearer` grant type and the grant-profile advertisement."""
transport = httpx.ASGITransport(app=tutorial002.auth_app)
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client:
transport = httpx2.ASGITransport(app=tutorial002.auth_app)
async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client:
response = await http_client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
metadata = response.json()
@ -167,7 +167,7 @@ async def test_the_whole_grant_is_one_token_request() -> None:
mounts = {"https://auth.example.com": RecordingASGITransport(tutorial002.auth_app, log)}
async with mcp.session_manager.run():
async with (
httpx.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client,
httpx2.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client,
Client(streamable_http_client(MCP_SERVER_URL, http_client=http_client)) as client,
):
result = await client.call_tool("whoami", {})

View file

@ -2,7 +2,7 @@
import inspect
import httpx
import httpx2
import pytest
from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
@ -64,8 +64,8 @@ async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a
app = MCPServer("Bookshop").streamable_http_app()
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
httpx2.ASGITransport(app) as transport,
httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
@ -80,8 +80,8 @@ async def test_stateless_http_never_mints_a_session() -> None:
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
httpx2.ASGITransport(app) as transport,
httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
@ -93,8 +93,8 @@ async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_
the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
async with (
tutorial002.app.router.lifespan_context(tutorial002.app),
httpx.ASGITransport(tutorial002.app) as transport,
httpx.AsyncClient(transport=transport) as http,
httpx2.ASGITransport(tutorial002.app) as transport,
httpx2.AsyncClient(transport=transport) as http,
):
modern_target = streamable_http_client(URL, http_client=http)
async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:

View file

@ -2,7 +2,7 @@
import inspect
import httpx
import httpx2
import pytest
from pydantic import AnyUrl, ValidationError
@ -42,8 +42,8 @@ async def test_storage_round_trips_tokens_and_client_info() -> None:
async def test_the_provider_is_an_httpx_auth() -> None:
"""tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP."""
assert isinstance(tutorial001.oauth, httpx.Auth)
"""tutorial001: `OAuthClientProvider` plugs into httpx2, not into MCP."""
assert isinstance(tutorial001.oauth, httpx2.Auth)
async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None:
@ -66,9 +66,9 @@ async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytes
async def test_client_credentials_provider_has_no_human_in_the_loop() -> None:
"""tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers."""
"""tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the handlers."""
assert isinstance(tutorial002.oauth, OAuthClientProvider)
assert isinstance(tutorial002.oauth, httpx.Auth)
assert isinstance(tutorial002.oauth, httpx2.Auth)
assert tutorial002.oauth.context.redirect_handler is None
assert tutorial002.oauth.context.callback_handler is None
@ -92,7 +92,7 @@ async def test_the_three_remaining_keyword_arguments_have_defaults() -> None:
async def test_the_one_more_provider_is_private_key_jwt() -> None:
"""The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way."""
"""The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx2.Auth`, built the same way."""
provider = PrivateKeyJWTOAuthProvider(
server_url="http://localhost:8001/mcp",
storage=tutorial002.InMemoryTokenStorage(),
@ -100,7 +100,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None:
assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
)
assert isinstance(provider, OAuthClientProvider)
assert isinstance(provider, httpx.Auth)
assert isinstance(provider, httpx2.Auth)
assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt"

View file

@ -3,7 +3,7 @@
import logging
from typing import Any
import httpx
import httpx2
import pytest
from mcp_types import (
INVALID_PARAMS,
@ -125,10 +125,10 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
caplog: pytest.LogCaptureFixture,
) -> None:
"""tutorial003: one 421, three spellings. The page presents all three as the same event."""
transport = httpx.ASGITransport(app=tutorial003.app)
transport = httpx2.ASGITransport(app=tutorial003.app)
async with tutorial003.mcp.session_manager.run():
# What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
async with httpx2.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
@ -137,7 +137,7 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
# What the server operator finds by grepping the server log.
assert "Invalid Host header: mcp.example.com" in caplog.messages
# What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
async with httpx.AsyncClient(transport=transport) as http_client:
async with httpx2.AsyncClient(transport=transport) as http_client:
client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
with pytest.raises(Exception) as exc_info: # pragma: no branch
await client.__aenter__() # the connection attempt itself is what fails
@ -147,9 +147,9 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
"""tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
transport = httpx.ASGITransport(app=tutorial004.app)
transport = httpx2.ASGITransport(app=tutorial004.app)
async with tutorial004.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
async with httpx2.AsyncClient(transport=transport) as http_client:
allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
async with Client(allowed) as c: # pragma: no branch
assert c.protocol_version == "2026-07-28"
@ -159,8 +159,8 @@ async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
"""tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
transport = httpx2.ASGITransport(app=tutorial005.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
@ -170,7 +170,7 @@ async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found
mcp = MCPServer("Weather")
app = mcp.streamable_http_app()
async with mcp.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
response = await h.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
@ -243,9 +243,9 @@ async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_suppo
async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
"""tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
transport = httpx.ASGITransport(app=tutorial008.app)
transport = httpx2.ASGITransport(app=tutorial008.app)
async with tutorial008.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
async with httpx2.AsyncClient(transport=transport) as http_client:
stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
async with Client(stateless) as c: # pragma: no branch
with pytest.raises(MCPError) as exc_info: # pragma: no branch