Files
modelcontextprotocol--pytho…/tests/interaction/lowlevel/test_wire.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

366 lines
17 KiB
Python

"""Wire-level invariants observed at the client's transport boundary.
These behaviours are invisible to API callers -- they are properties of the raw JSON-RPC frames.
The tests wrap the in-memory transport in a RecordingTransport, which tees every message crossing
the transport seam into a list without touching the session, so the assertions hold for whatever
the session implementation sends rather than for what its API returns.
The later tests drive the wire by hand instead: one closes the server-to-client stream while a
request is in flight to pin the connection-closed teardown, and the last two send deliberately
malformed JSON-RPC requests that the typed client API cannot produce.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CONNECTION_CLOSED,
INVALID_PARAMS,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
EmptyResult,
ErrorData,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListRootsResult,
TextContent,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client._memory import InMemoryTransport
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._helpers import RecordingTransport, _RecordingReadStream
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _echo_server() -> Server:
"""A server with one echo tool, used by every test in this module."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="echo", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="ok")])
return Server("wire", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("protocol:request-id:unique")
async def test_request_ids_are_unique_and_never_null() -> None:
"""Every request the client sends carries a distinct, non-null id.
The id sequence is pinned: sequential integers from one, in send order.
"""
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy") as client:
await client.list_tools()
await client.call_tool("echo", {})
await client.call_tool("echo", {})
await client.send_ping() # pyright: ignore[reportDeprecated]
sent = [message.message for message in recording.sent]
request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
assert all(request_id is not None for request_id in request_ids)
assert len(request_ids) == len(set(request_ids))
# initialize, tools/list, tools/call, tools/call, ping -- the client does not issue a
# schema-cache refresh here because the explicit tools/list already populated the cache.
assert request_ids == snapshot([1, 2, 3, 4, 5])
@requirement("protocol:notifications:no-response")
async def test_notifications_are_never_answered() -> None:
"""A notification produces no response: everything the server sends back answers a request.
The client sends two notifications (initialized and roots/list_changed) and several requests;
the messages received from the server must be exactly one response per request, each carrying
the id of the request it answers, and nothing else.
"""
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
"""Registered so the client declares the roots capability; the server never asks for roots."""
raise NotImplementedError
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy", list_roots_callback=list_roots) as client:
await client.send_roots_list_changed() # pyright: ignore[reportDeprecated]
await client.send_ping() # pyright: ignore[reportDeprecated]
sent = [message.message for message in recording.sent]
sent_request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
sent_notifications = [message for message in sent if isinstance(message, JSONRPCNotification)]
received = [message.message for message in recording.received if isinstance(message, SessionMessage)]
received_responses = [message for message in received if isinstance(message, JSONRPCResponse)]
assert len(sent_notifications) == 2 # notifications/initialized and notifications/roots/list_changed
assert len(received_responses) == len(received) # nothing the server sent was anything but a response
assert [message.id for message in received_responses] == sent_request_ids
async def test_recording_read_stream_ends_iteration_when_the_sender_closes() -> None:
"""The recording wrapper preserves the end-of-stream behaviour of the stream it wraps.
This exercises the helper itself rather than an interaction-model behaviour: a transport whose
far end closes must end the client's receive loop cleanly, and the wrapper must not swallow or
mistranslate that.
"""
send_stream, receive_stream = anyio.create_memory_object_stream[SessionMessage | Exception](1)
log: list[SessionMessage | Exception] = []
async with send_stream, _RecordingReadStream(receive_stream, log) as wrapped:
await send_stream.aclose()
items = [item async for item in wrapped]
assert items == []
assert log == []
@requirement("lifecycle:initialized-notification")
async def test_exactly_one_initialized_notification_is_sent_after_the_handshake() -> None:
"""The client sends initialized exactly once, between the initialize response and its first request.
The full method sequence the client puts on the wire is pinned in send order.
"""
recording = RecordingTransport(InMemoryTransport(_echo_server()))
async with Client(recording, mode="legacy") as client:
await client.list_tools()
sent_methods = [
message.message.method
for message in recording.sent
if isinstance(message.message, JSONRPCRequest | JSONRPCNotification)
]
assert sent_methods.count("notifications/initialized") == 1
assert sent_methods == snapshot(["initialize", "notifications/initialized", "tools/list"])
@requirement("protocol:error:connection-closed")
async def test_closing_the_transport_fails_in_flight_requests_with_connection_closed() -> None:
"""When the server-to-client stream closes, every in-flight client request fails with CONNECTION_CLOSED.
Driven over a bare ClientSession against a real Server so the test holds the transport stream
pair directly: once the request is in flight (the server handler signals it has started) the
test closes the server's write stream, which ends the client's receive loop and triggers the
teardown that fails the pending request.
"""
handler_started = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
raise NotImplementedError # unreachable: the wait above never completes normally
server = Server("blocker", on_call_tool=call_tool)
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
errors: list[ErrorData] = []
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
async with ClientSession(client_read, client_write) as session:
with anyio.fail_after(5):
await session.initialize()
async def call_and_capture_error() -> None:
with pytest.raises(MCPError) as exc_info:
await session.send_request(
CallToolRequest(params=CallToolRequestParams(name="block")), CallToolResult
)
errors.append(exc_info.value.error)
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(call_and_capture_error)
await handler_started.wait()
await server_write.aclose()
server_task_group.cancel_scope.cancel()
assert errors == snapshot([ErrorData(code=CONNECTION_CLOSED, message="Connection closed")])
@requirement("protocol:error:invalid-params")
async def test_malformed_request_params_are_answered_with_invalid_params() -> None:
"""A request whose params fail validation is answered with -32602 Invalid params.
The typed client API cannot construct a request with the wrong parameter types, so the test
plays the client's side of the wire by hand against a real Server: it completes the
initialization handshake at the JSON-RPC layer and then sends a tools/call whose `name` is an
integer. Reserve this pattern for behaviour the typed API cannot produce.
"""
server = Server("strict")
errors: list[ErrorData] = []
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
with anyio.fail_after(5):
await client_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=0,
method="initialize",
params={
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "0.0.1"},
},
)
)
)
init_response = await client_read.receive()
assert isinstance(init_response, SessionMessage)
assert isinstance(init_response.message, JSONRPCResponse)
await client_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))
)
await client_write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": 42}))
)
error_response = await client_read.receive()
assert isinstance(error_response, SessionMessage)
assert isinstance(error_response.message, JSONRPCError)
errors.append(error_response.message.error)
server_task_group.cancel_scope.cancel()
assert errors == snapshot([ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")])
@requirement("logging:set-level:invalid-level")
async def test_set_level_with_an_unrecognized_value_is_answered_with_invalid_params() -> None:
"""logging/setLevel with a value outside the spec's level enum is answered with -32602 Invalid params.
The typed client API cannot construct a setLevel request with an unrecognized level (pyright and
the client-side model both reject it), so the test plays the client's side of the wire by hand
against a real Server. Reserve this pattern for behaviour the typed API cannot produce.
"""
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; never called -- params validation fails first."""
raise NotImplementedError
server = Server("logger", on_set_logging_level=set_logging_level) # pyright: ignore[reportDeprecated]
errors: list[ErrorData] = []
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as server_task_group:
server_task_group.start_soon(server.run, server_read, server_write, server.create_initialization_options())
with anyio.fail_after(5):
await client_write.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=0,
method="initialize",
params={
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "0.0.1"},
},
)
)
)
init_response = await client_read.receive()
assert isinstance(init_response, SessionMessage)
assert isinstance(init_response.message, JSONRPCResponse)
await client_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))
)
await client_write.send(
SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "loud"})
)
)
error_response = await client_read.receive()
assert isinstance(error_response, SessionMessage)
assert isinstance(error_response.message, JSONRPCError)
errors.append(error_response.message.error)
server_task_group.cancel_scope.cancel()
assert len(errors) == 1
assert errors[0].code == INVALID_PARAMS
@requirement("protocol:cancel:stream-frame")
async def test_abandoning_a_call_on_a_modern_stream_wire_sends_one_cancelled_frame() -> None:
"""At 2026-07-28 over a stream (stdio-shaped) wire, abandoning an in-flight call puts exactly
one notifications/cancelled naming that request on the wire, and the frame interrupts the
server-side handler - stream wires keep the frame spelling that 2026 streamable HTTP dropped.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError # registered so tools/call is served; the stream wire never lists
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
recording = RecordingTransport(InMemoryTransport(server))
async with Client(recording, mode="2026-07-28") as client:
abandon = anyio.CancelScope()
async def call_and_abandon() -> None:
with abandon:
await client.call_tool("block", {})
raise NotImplementedError # unreachable: the call never resolves
async with anyio.create_task_group() as tg:
tg.start_soon(call_and_abandon)
with anyio.fail_after(5):
await handler_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await handler_cancelled.wait()
# Let the cancelled call's late error response arrive and be dropped while the client
# is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
call, cancel = [message.message for message in recording.sent]
assert isinstance(call, JSONRPCRequest)
assert call.method == "tools/call"
assert isinstance(cancel, JSONRPCNotification)
assert cancel.method == "notifications/cancelled"
assert cancel.params == {"requestId": call.id, "reason": "caller cancelled"}