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
20 changed files with 1029 additions and 948 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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, _):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue