[v2] Dispatcher/ServerRunner receive-path swap — replaces BaseSession (#2710)

This commit is contained in:
Max 2026-06-09 12:58:47 +01:00 • committed by GitHub
commit 5d826490b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 6594 additions and 1701 deletions

View file

@ -1,6 +1,11 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import anyio
import anyio.streams.memory
import pytest
from mcp import types
@ -10,12 +15,14 @@ from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
CallToolResult,
Implementation,
InitializedNotification,
InitializeRequest,
InitializeResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
@ -26,6 +33,29 @@ from mcp.types import (
client_request_adapter,
)
_SendToClient = anyio.streams.memory.MemoryObjectSendStream[SessionMessage | Exception]
_RecvFromClient = anyio.streams.memory.MemoryObjectReceiveStream[SessionMessage]
@asynccontextmanager
async def raw_client_session(
**kwargs: Any,
) -> AsyncIterator[tuple[ClientSession, _SendToClient, _RecvFromClient]]:
"""Yield `(session, send_to_client, recv_from_client)` with the receive loop running.
`send_to_client` accepts `SessionMessage | Exception` so tests can inject
transport-level exceptions. No initialize handshake is performed.
"""
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](32)
async with ClientSession(s2c_recv, c2s_send, **kwargs) as session:
try:
with anyio.fail_after(5):
yield session, s2c_send, c2s_recv
finally:
s2c_send.close()
c2s_recv.close()
@pytest.mark.anyio
async def test_client_session_initialize():
@ -705,3 +735,129 @@ async def test_client_tool_call_with_meta(meta: RequestParamsMeta | None):
await session.initialize()
await session.call_tool(name=mocked_tool.name, arguments={"foo": "bar"}, meta=meta)
@pytest.mark.anyio
async def test_receive_loop_answers_malformed_inbound_request_with_invalid_params():
"""A request that fails ServerRequest validation gets an INVALID_PARAMS error response."""
async with raw_client_session() as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=7, method="sampling/createMessage", params={"broken": 1}))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.id == 7
assert out.message.error.code == INVALID_PARAMS
@pytest.mark.anyio
async def test_receive_loop_answers_invalid_params_when_sampling_callback_raises():
"""Same boundary catches exceptions from the request handler itself."""
async def boom(ctx: object, params: object) -> types.CreateMessageResult:
raise RuntimeError("sampling boom")
params = types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=10,
).model_dump(by_alias=True, mode="json", exclude_none=True)
async with raw_client_session(sampling_callback=boom) as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=8, method="sampling/createMessage", params=params))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.error.code == INVALID_PARAMS
@pytest.mark.anyio
async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest.LogCaptureFixture):
"""A notification that fails ServerNotification validation is logged and dropped."""
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
await to_client.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="not/a/spec/notification")))
# Follow with a valid notification so we know the loop is still alive.
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert isinstance(seen[0], types.ToolListChangedNotification)
assert "Failed to validate notification" in caplog.text
@pytest.mark.anyio
async def test_receive_loop_forwards_transport_exception_to_message_handler():
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
exc = ValueError("bad bytes")
await to_client.send(exc)
await delivered.wait()
assert seen == [exc]
@pytest.mark.anyio
async def test_receive_loop_consumes_server_cancelled_without_reaching_message_handler():
"""A server-sent notifications/cancelled is swallowed, matching the pre-swap contract.
The server dispatcher now emits this on sampling/elicitation timeout, but
ClientSession has no in-flight tracking to act on it, so surfacing it would
only break user handlers that exhaustively match ServerNotification.
"""
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1, "reason": "timed out"}
)
)
)
# Follow with a notification that does reach the handler so we can
# assert ordering deterministically.
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert len(seen) == 1
assert isinstance(seen[0], types.ToolListChangedNotification)
@pytest.mark.anyio
async def test_receive_loop_swallows_progress_callback_exception(caplog: pytest.LogCaptureFixture):
delivered = anyio.Event()
async def boom(progress: float, total: float | None, message: str | None) -> None:
raise RuntimeError("progress boom")
async def handler(msg: object) -> None:
delivered.set()
async with raw_client_session(message_handler=handler) as (session, to_client, _):
# Register the callback under a known token without sending a request.
session._progress_callbacks[42] = boom # pyright: ignore[reportPrivateUsage]
params = {"progressToken": 42, "progress": 0.5}
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params=params))
)
# The progress notification also reaches the message handler after the
# callback runs, so this fires once the callback's exception is handled.
await delivered.wait()
assert "Progress callback raised an exception" in caplog.text

View file

@ -1,8 +1,15 @@
"""Tests for InMemoryTransport."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import anyio
import anyio.lowlevel
import pytest
from mcp import Client, types
from mcp.client import _memory
from mcp.client._memory import InMemoryTransport
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
@ -95,3 +102,47 @@ async def test_raise_exceptions(mcpserver_server: MCPServer):
transport = InMemoryTransport(mcpserver_server, raise_exceptions=True)
async with transport as (read_stream, _write_stream):
assert read_stream is not None
async def test_aexit_with_well_behaved_lifespan_runs_teardown_without_cancel():
"""A lifespan that finishes promptly on EOF should run to completion.
The transport closes the streams first and waits for the server to exit
naturally, so teardown observes no cancellation.
"""
teardown_ran = anyio.Event()
@asynccontextmanager
async def lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
yield {}
await anyio.lowlevel.checkpoint()
teardown_ran.set()
server = Server(name="test_server", lifespan=lifespan)
with anyio.fail_after(5):
async with InMemoryTransport(server):
pass
assert teardown_ran.is_set()
async def test_aexit_with_blocking_lifespan_is_bounded(monkeypatch: pytest.MonkeyPatch):
"""A lifespan that never returns must not hang `__aexit__` forever.
After EOFing the server the transport waits `SERVER_SHUTDOWN_GRACE` for a
natural exit, then cancels the server task as a backstop so the
task-group join completes.
"""
monkeypatch.setattr(_memory, "SERVER_SHUTDOWN_GRACE", 0.05)
teardown_started = anyio.Event()
@asynccontextmanager
async def blocking_lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
yield {}
teardown_started.set()
await anyio.Event().wait()
server = Server(name="test_server", lifespan=blocking_lifespan)
with anyio.fail_after(5):
async with InMemoryTransport(server):
pass
assert teardown_started.is_set()