chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user