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
366 lines
17 KiB
Python
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"}
|