Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
1216c53693
commit
2713b53b12
1 changed files with 1029 additions and 948 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue