49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
751 lines
33 KiB
Python
751 lines
33 KiB
Python
"""Unit tests for the streamable-HTTP client transport.
|
|
|
|
The full client<->server round trip is pinned by the interaction suite under
|
|
tests/interaction/transports/; these tests cover the transport's header encoding and the
|
|
per-message metadata-headers merge directly because the headers are an HTTP-seam observation
|
|
the public client never exposes.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
from collections.abc import AsyncIterator, Callable, Mapping
|
|
from typing import Any
|
|
|
|
import anyio
|
|
import httpx
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import (
|
|
CLIENT_CAPABILITIES_META_KEY,
|
|
CLIENT_INFO_META_KEY,
|
|
CONNECTION_CLOSED,
|
|
INVALID_REQUEST,
|
|
METHOD_NOT_FOUND,
|
|
PROTOCOL_VERSION_META_KEY,
|
|
JSONRPCError,
|
|
JSONRPCNotification,
|
|
JSONRPCRequest,
|
|
JSONRPCResponse,
|
|
)
|
|
from mcp_types.version import LATEST_MODERN_VERSION
|
|
from starlette.types import Receive, Scope, Send
|
|
|
|
from mcp.client.streamable_http import (
|
|
MAX_RECONNECTION_ATTEMPTS,
|
|
RequestContext,
|
|
StreamableHTTPTransport,
|
|
streamable_http_client,
|
|
)
|
|
from mcp.server import Server
|
|
from mcp.server._streamable_http_modern import handle_modern_request
|
|
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
|
|
from mcp.shared._context_streams import ContextSendStream, create_context_streams
|
|
from mcp.shared.dispatcher import CallOptions, DispatchContext
|
|
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
|
|
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
|
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
|
|
from mcp.shared.transport_context import TransportContext
|
|
from tests.interaction.transports import StreamingASGITransport
|
|
from tests.shared.test_dispatcher import Recorder, echo_handlers
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected", "wrapped"),
|
|
[
|
|
("add", snapshot("add"), False),
|
|
("", snapshot(""), False),
|
|
("tool with spaces", snapshot("tool with spaces"), False),
|
|
(" add", snapshot("=?base64?IGFkZA==?="), True),
|
|
("add ", snapshot("=?base64?YWRkIA==?="), True),
|
|
("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
|
|
("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
|
|
("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
|
|
],
|
|
)
|
|
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
|
|
raw: str, expected: str, wrapped: bool
|
|
) -> None:
|
|
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped.
|
|
|
|
The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
|
|
Wrapped values round-trip through base64 so the server can recover the original name. A leading
|
|
or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
|
|
transports); an empty value is allowed and passes verbatim.
|
|
"""
|
|
encoded = encode_header_value(raw)
|
|
assert encoded == expected
|
|
if wrapped:
|
|
assert encoded.startswith("=?base64?") and encoded.endswith("?=")
|
|
assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
|
|
else:
|
|
assert encoded == raw
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_post_request_merges_per_message_metadata_headers() -> None:
|
|
"""`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers
|
|
(SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
|
|
recorded: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
recorded.append(request)
|
|
body = json.loads(request.content)
|
|
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}),
|
|
metadata=ClientMessageMetadata(headers={"x-test": "v"}),
|
|
)
|
|
)
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert [r.method for r in recorded] == ["POST"]
|
|
assert recorded[0].headers["x-test"] == "v"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_pre_session_bare_404_maps_to_method_not_found() -> None:
|
|
"""A bare HTTP 404 (no JSON-RPC body) before any session-id is held maps to METHOD_NOT_FOUND.
|
|
|
|
Gateways and legacy servers 404 at the HTTP layer for unknown methods; with no session yet,
|
|
"Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(404)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={})))
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCError)
|
|
assert reply.message.error.code == METHOD_NOT_FOUND
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
|
|
"""``initialize`` discards the cached protocol-version header; every other POST reads it.
|
|
|
|
Steps:
|
|
1. A stamped probe POST caches its ``MCP-Protocol-Version`` header.
|
|
2. An ``initialize`` POST clears that cache before building headers, so the fallback
|
|
handshake never carries a probe-stamped value.
|
|
3. A subsequent stamped POST re-seeds the cache with the negotiated version.
|
|
4. An unstamped POST (a JSON-RPC response written by the dispatcher, which never
|
|
passes through the session's stamp) then reads the cache and carries the
|
|
negotiated version — the spec MUST for all post-initialization HTTP requests.
|
|
"""
|
|
recorded: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
recorded.append(request)
|
|
body = json.loads(request.content)
|
|
if "id" not in body or "result" in body:
|
|
return httpx.Response(202)
|
|
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2026-07-28"}),
|
|
)
|
|
)
|
|
await read.receive()
|
|
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={})))
|
|
await read.receive()
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
|
|
)
|
|
)
|
|
# An unstamped JSON-RPC response — what the dispatcher writes when answering
|
|
# a server-initiated request (sampling/elicitation/roots).
|
|
await write.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=99, result={})))
|
|
|
|
assert [r.method for r in recorded] == ["POST", "POST", "POST", "POST"]
|
|
assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28"
|
|
assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers
|
|
assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
|
|
assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
|
|
|
|
|
|
class _ParkedSSEStream(httpx.AsyncByteStream):
|
|
"""An SSE response body that emits one comment line, then parks until closed.
|
|
|
|
`opened` fires once the transport is iterating the body (the POST is truly in
|
|
flight); `closed` fires when httpx tears the body down — the observable proof
|
|
that an abort, not a response, ended the stream.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.opened = anyio.Event()
|
|
self.closed = anyio.Event()
|
|
self._release = anyio.Event()
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
self.opened.set()
|
|
yield b": parked\n\n"
|
|
await self._release.wait()
|
|
|
|
async def aclose(self) -> None:
|
|
self.closed.set()
|
|
self._release.set()
|
|
|
|
|
|
def _sse_or_ack_handler(
|
|
parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event
|
|
) -> Callable[[httpx.Request], httpx.Response]:
|
|
"""Requests get the parked SSE body; notifications get 202 and set `frame_posted`."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content)
|
|
posted.append(body)
|
|
if "id" in body:
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
|
|
frame_posted.set()
|
|
return httpx.Response(202)
|
|
|
|
return handler
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> None:
|
|
"""At 2026 an outbound `notifications/cancelled` never POSTs — closing the named
|
|
request's response stream IS the wire's cancellation signal — so the transport
|
|
aborts the in-flight POST and swallows the frame."""
|
|
parked = _ParkedSSEStream()
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
|
|
)
|
|
)
|
|
await parked.opened.wait()
|
|
await write.send(
|
|
SessionMessage(
|
|
JSONRPCNotification(
|
|
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "listen-1"}
|
|
)
|
|
)
|
|
)
|
|
await parked.closed.wait()
|
|
assert [body["method"] for body in posted] == ["subscriptions/listen"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"])
|
|
async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None:
|
|
"""Below 2026 — or before any stamped POST has revealed the version — the frame is
|
|
the spec's cancellation signal: it POSTs, and the request's stream stays open
|
|
(a 2025 disconnect is explicitly not a cancel)."""
|
|
parked = _ParkedSSEStream()
|
|
posted: list[dict[str, Any]] = []
|
|
frame_posted = anyio.Event()
|
|
handler = _sse_or_ack_handler(parked, posted, frame_posted)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
|
|
):
|
|
metadata = (
|
|
ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: stamped_version})
|
|
if stamped_version is not None
|
|
else None
|
|
)
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
|
|
metadata=metadata,
|
|
)
|
|
)
|
|
await parked.opened.wait()
|
|
await write.send(
|
|
SessionMessage(
|
|
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
|
|
)
|
|
)
|
|
await frame_posted.wait()
|
|
# Checked before teardown: exiting the transport cancels the parked POST.
|
|
assert not parked.closed.is_set()
|
|
assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
"params",
|
|
[
|
|
pytest.param({"requestId": 999}, id="unknown-id"),
|
|
pytest.param({"requestId": True}, id="bool-must-not-alias-request-id-1"),
|
|
pytest.param({"requestId": "1"}, id="string-1-must-not-match-int-1"),
|
|
pytest.param({}, id="no-request-id"),
|
|
pytest.param(None, id="no-params"),
|
|
],
|
|
)
|
|
async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: dict[str, Any] | None) -> None:
|
|
"""At 2026 the frame is swallowed even when it aborts nothing — the wire defines no
|
|
client-to-server notifications, so a late cancel racing the response must not leak
|
|
a POST — and a mismatched id must not abort someone else's stream."""
|
|
parked = _ParkedSSEStream()
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content)
|
|
posted.append(body)
|
|
if body.get("id") == 1:
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
|
|
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="subscriptions/listen", params={}),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
|
|
)
|
|
)
|
|
await parked.opened.wait()
|
|
await write.send(
|
|
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params=params))
|
|
)
|
|
# A follow-up request completing proves the loop moved past the swallowed frame.
|
|
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={})))
|
|
reply = await read.receive()
|
|
# Checked before teardown: exiting the transport cancels the parked POST.
|
|
assert not parked.closed.is_set()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCResponse)
|
|
assert reply.message.id == 2
|
|
assert [body["method"] for body in posted] == ["subscriptions/listen", "ping"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> None:
|
|
"""A cancel carrying `ServerMessageMetadata` (a handler abandoning its own
|
|
back-channel request) still names one of OUR outbound ids — every spec-legal
|
|
cancel names a request its sender issued — so at 2026 it aborts that POST and
|
|
stays off the wire like any other."""
|
|
parked = _ParkedSSEStream()
|
|
posted: list[dict[str, Any]] = []
|
|
frame_posted = anyio.Event()
|
|
handler = _sse_or_ack_handler(parked, posted, frame_posted)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
|
|
)
|
|
)
|
|
await parked.opened.wait()
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCNotification(
|
|
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}
|
|
),
|
|
metadata=ServerMessageMetadata(related_request_id=99),
|
|
)
|
|
)
|
|
await parked.closed.wait()
|
|
assert [body["method"] for body in posted] == ["tools/call"]
|
|
assert not frame_posted.is_set()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None:
|
|
"""The translation follows the era the NAMED request was sent under, not the
|
|
cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation
|
|
semantics (frame on the wire, stream left open) even after a later message
|
|
flips the negotiated version to 2026."""
|
|
parked = _ParkedSSEStream()
|
|
posted: list[dict[str, Any]] = []
|
|
frame_posted = anyio.Event()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content)
|
|
posted.append(body)
|
|
if body.get("id") == 1:
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
|
|
if "id" in body:
|
|
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
|
|
frame_posted.set()
|
|
return httpx.Response(202)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
|
|
)
|
|
)
|
|
await parked.opened.wait()
|
|
# A modern-stamped request flips the cached negotiated version.
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}),
|
|
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
|
|
)
|
|
)
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCResponse)
|
|
await write.send(
|
|
SessionMessage(
|
|
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
|
|
)
|
|
)
|
|
await frame_posted.wait()
|
|
# Checked before teardown: exiting the transport cancels the parked POST.
|
|
assert not parked.closed.is_set()
|
|
assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"]
|
|
|
|
|
|
class _SignalingBus(InMemorySubscriptionBus):
|
|
"""Signals subscribe/unsubscribe so a test observes the stream lifecycle through
|
|
the bus Protocol (the public seam) instead of polling handler internals."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.subscribed = anyio.Event()
|
|
self.unsubscribed = anyio.Event()
|
|
|
|
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
|
unsubscribe = super().subscribe(listener)
|
|
self.subscribed.set()
|
|
|
|
def unsubscribe_and_signal() -> None:
|
|
unsubscribe()
|
|
self.unsubscribed.set()
|
|
|
|
return unsubscribe_and_signal
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
|
|
"""Over a real ASGI bridge: cancelling the caller of a parked `subscriptions/listen`
|
|
closes the POST's response stream — the server treats the disconnect as the cancel
|
|
and releases the subscription — and no `notifications/cancelled` crosses the wire."""
|
|
bus = _SignalingBus()
|
|
server = Server("test", on_subscriptions_listen=ListenHandler(bus))
|
|
|
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
async with server.lifespan(server) as lifespan_state:
|
|
await handle_modern_request(server, None, False, lifespan_state, scope, receive, send)
|
|
|
|
posted_methods: list[str] = []
|
|
|
|
async def record_request(request: httpx.Request) -> None:
|
|
posted_methods.append(json.loads(request.content)["method"])
|
|
|
|
acked = anyio.Event()
|
|
|
|
async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
|
|
assert method == "notifications/subscriptions/acknowledged"
|
|
acked.set()
|
|
|
|
on_request, _ = echo_handlers(Recorder())
|
|
|
|
with anyio.fail_after(15):
|
|
async with (
|
|
httpx.AsyncClient(
|
|
transport=StreamingASGITransport(app),
|
|
base_url="http://testserver",
|
|
event_hooks={"request": [record_request]},
|
|
) as http,
|
|
streamable_http_client("http://testserver/mcp", http_client=http) as (read, write),
|
|
):
|
|
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read, write)
|
|
async with anyio.create_task_group() as tg: # pragma: no branch
|
|
await tg.start(dispatcher.run, on_request, on_notify)
|
|
listen_scope = anyio.CancelScope()
|
|
|
|
async def send_listen() -> None:
|
|
params: dict[str, Any] = {
|
|
"_meta": {
|
|
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
|
|
CLIENT_INFO_META_KEY: {"name": "test-client", "version": "0"},
|
|
CLIENT_CAPABILITIES_META_KEY: {},
|
|
},
|
|
"notifications": {"toolsListChanged": True},
|
|
}
|
|
opts: CallOptions = {
|
|
"request_id": "listen-1",
|
|
"headers": {
|
|
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
|
|
MCP_METHOD_HEADER: "subscriptions/listen",
|
|
},
|
|
}
|
|
with listen_scope:
|
|
await dispatcher.send_raw_request("subscriptions/listen", params, opts)
|
|
|
|
tg.start_soon(send_listen)
|
|
await acked.wait()
|
|
assert bus.subscribed.is_set()
|
|
assert not bus.unsubscribed.is_set()
|
|
listen_scope.cancel()
|
|
await bus.unsubscribed.wait()
|
|
tg.cancel_scope.cancel()
|
|
assert posted_methods == ["subscriptions/listen"]
|
|
|
|
|
|
class _CompletingSSEStream(httpx.AsyncByteStream):
|
|
"""An SSE body that delivers one JSON-RPC response, then parks in `aclose`.
|
|
|
|
Holding `aclose` keeps the finished POST task alive past its response, so a
|
|
test can re-register the same request id underneath it before releasing.
|
|
"""
|
|
|
|
def __init__(self, response_body: dict[str, Any]) -> None:
|
|
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
|
|
self.release = anyio.Event()
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
yield self._event
|
|
|
|
async def aclose(self) -> None:
|
|
await self.release.wait()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration() -> None:
|
|
"""Request ids are reusable once resolved; a finished POST task unwinding late
|
|
must not pop the successor's registration, or a cancel for the reused id would
|
|
find nothing to abort and the live POST would leak past the cancellation."""
|
|
completing = _CompletingSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {}})
|
|
parked = _ParkedSSEStream()
|
|
posted: list[dict[str, Any]] = []
|
|
streams = [completing, parked]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}),
|
|
metadata=modern,
|
|
)
|
|
)
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCResponse)
|
|
# The first task is now parked in `aclose`; reuse its id underneath it.
|
|
await write.send(
|
|
SessionMessage(
|
|
message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="subscriptions/listen", params={}),
|
|
metadata=modern,
|
|
)
|
|
)
|
|
await parked.opened.wait()
|
|
completing.release.set()
|
|
await anyio.wait_all_tasks_blocked()
|
|
# The successor's registration survived: a cancel still aborts it.
|
|
await write.send(
|
|
SessionMessage(
|
|
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "dup-1"})
|
|
)
|
|
)
|
|
await parked.closed.wait()
|
|
assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"]
|
|
|
|
|
|
class _DyingSSEStream(httpx.AsyncByteStream):
|
|
"""Emits one id-less comment then breaks - a non-resumable stream dropping."""
|
|
|
|
def __init__(self) -> None:
|
|
self.opened = anyio.Event()
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
self.opened.set()
|
|
yield b": hello\n\n"
|
|
raise httpx.ReadError("connection reset")
|
|
|
|
async def aclose(self) -> None:
|
|
pass
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None:
|
|
"""A per-request SSE stream that dies having carried no event ids can never deliver its
|
|
response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
|
|
dying = _DyingSSEStream()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
|
|
)
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCError)
|
|
assert reply.message.id == "listen-1"
|
|
assert reply.message.error.code == CONNECTION_CLOSED
|
|
|
|
|
|
class _DeliverOnCommandSSEStream(httpx.AsyncByteStream):
|
|
"""Parks after opening, then delivers one JSON-RPC response when told."""
|
|
|
|
def __init__(self, response_body: dict[str, Any]) -> None:
|
|
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
|
|
self.opened = anyio.Event()
|
|
self.deliver = anyio.Event()
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
self.opened.set()
|
|
await self.deliver.wait()
|
|
yield self._event
|
|
|
|
async def aclose(self) -> None:
|
|
pass
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None:
|
|
"""SDK-defined: re-issuing an id severs the superseded POST, so nothing from its
|
|
stream (a late real response, or a synthesized error for its death) can resolve
|
|
the reused id's waiter; only the successor's own response arrives."""
|
|
stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}})
|
|
succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}})
|
|
streams: list[httpx.AsyncByteStream] = [stale, succeeding]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
|
|
await stale.opened.wait()
|
|
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
|
|
await succeeding.opened.wait()
|
|
stale.deliver.set()
|
|
await anyio.wait_all_tasks_blocked()
|
|
succeeding.deliver.set()
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCResponse), reply.message
|
|
assert reply.message.result == {"origin": "fresh"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
|
|
"""SDK-defined: a server that answers a request with 202 Accepted has declared no
|
|
response will follow (the spec requires SSE or JSON for requests); the transport
|
|
resolves the waiter with INVALID_REQUEST instead of parking the caller forever."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(202)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
|
|
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
|
|
):
|
|
await write.send(
|
|
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
|
|
)
|
|
reply = await read.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCError)
|
|
assert reply.message.id == "listen-1"
|
|
assert reply.message.error.code == INVALID_REQUEST
|
|
|
|
|
|
def _abandoned_request_context(
|
|
http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
|
|
) -> RequestContext:
|
|
return RequestContext(
|
|
client=http,
|
|
session_id=None,
|
|
session_message=SessionMessage(
|
|
JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})
|
|
),
|
|
metadata=None,
|
|
read_stream_writer=send,
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None:
|
|
"""An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED."""
|
|
transport = StreamableHTTPTransport("http://test/mcp")
|
|
send, receive = create_context_streams[SessionMessage | Exception](1)
|
|
async with httpx.AsyncClient() as http:
|
|
with anyio.fail_after(5):
|
|
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
|
|
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
|
|
)
|
|
reply = await receive.receive()
|
|
assert isinstance(reply, SessionMessage)
|
|
assert isinstance(reply.message, JSONRPCError)
|
|
assert reply.message.id == "listen-1"
|
|
assert reply.message.error.code == CONNECTION_CLOSED
|
|
send.close()
|
|
receive.close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None:
|
|
"""Teardown race: a stream dying after the reader closed resolves best-effort and must not crash."""
|
|
transport = StreamableHTTPTransport("http://test/mcp")
|
|
send, receive = create_context_streams[SessionMessage | Exception](1)
|
|
receive.close()
|
|
async with httpx.AsyncClient() as http:
|
|
with anyio.fail_after(5):
|
|
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
|
|
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
|
|
)
|
|
send.close()
|