Files
modelcontextprotocol--pytho…/tests/interaction/lowlevel/test_cancellation.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

468 lines
20 KiB
Python

"""Cancellation interactions against the low-level Server, driven through the public Client API.
Client-side, cancelling means abandoning: cancelling the task that awaits a call makes the SDK
carry the signal in the transport's own spelling (a cancelled frame on stream wires, closing the
request's own response stream at 2026-07-28 streamable HTTP). The receiving-side tests instead
script a CancelledNotification by hand, capturing the request id from inside the blocked handler.
Handlers block on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
REQUEST_TIMEOUT,
CallToolResult,
EmptyResult,
ErrorData,
Implementation,
InitializeResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
PingRequest,
ServerCapabilities,
TextContent,
Tool,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("protocol:cancel:in-flight")
@requirement("protocol:cancel:handler-abort-propagates")
async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None:
"""Cancelling an in-flight request interrupts its handler and fails the pending call.
The server answers the cancelled request with an error response (the spec says it should
not respond at all; see the divergence note on the requirement), so the caller's pending
request raises rather than hanging.
"""
started = anyio.Event()
handler_cancelled = anyio.Event()
request_ids: list[types.RequestId] = []
errors: list[ErrorData] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
assert ctx.request_id is not None
request_ids.append(ctx.request_id)
started.set()
try:
await anyio.Event().wait() # blocks until cancelled; nothing ever sets this event
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable: the wait above never completes normally
server = Server("blocker", on_call_tool=call_tool)
async with connect(server) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group:
async def call_and_capture_error() -> None:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("block", {})
errors.append(exc_info.value.error)
task_group.start_soon(call_and_capture_error)
await started.wait()
await client.session.send_notification(
types.CancelledNotification(
params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted")
)
)
await handler_cancelled.wait()
assert errors == snapshot([ErrorData(code=0, message="Request cancelled")])
@requirement("protocol:cancel:server-survives")
async def test_session_serves_requests_after_cancellation(connect: Connect) -> None:
"""A request cancelled mid-flight does not poison the session: the next request succeeds."""
started = anyio.Event()
request_ids: list[types.RequestId] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(name="block", input_schema={"type": "object"}),
types.Tool(name="echo", input_schema={"type": "object"}),
]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "echo":
return CallToolResult(content=[TextContent(text="still alive")])
assert ctx.request_id is not None
request_ids.append(ctx.request_id)
started.set()
await anyio.Event().wait() # blocks until cancelled
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group:
async def call_and_swallow_cancellation_error() -> None:
with pytest.raises(MCPError):
await client.call_tool("block", {})
task_group.start_soon(call_and_swallow_cancellation_error)
await started.wait()
await client.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0]))
)
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
@requirement("protocol:cancel:unknown-id-ignored")
async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None:
"""A cancellation referencing a request id that is not in flight is ignored without error."""
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="unbothered")])
server = Server("calm", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
await client.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=9999))
)
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
@requirement("protocol:cancel:server-to-client")
async def test_abandoned_server_request_cancels_the_client_callback(connect: Connect) -> None:
"""A server that abandons a sampling request cancels it, interrupting the client's callback mid-await."""
callback_started = anyio.Event()
callback_cancelled = anyio.Event()
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
try:
await anyio.Event().wait() # blocks until the cancellation interrupts it
except anyio.get_cancelled_exc_class():
callback_cancelled.set()
raise
raise NotImplementedError # unreachable
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
async with anyio.create_task_group() as abandon_scope:
async def sample() -> None:
await ctx.session.send_request(request, types.CreateMessageResult)
raise NotImplementedError # unreachable: the scope is cancelled
abandon_scope.start_soon(sample)
with anyio.fail_after(5):
await callback_started.wait()
abandon_scope.cancel_scope.cancel()
with anyio.fail_after(5):
await callback_cancelled.wait()
return CallToolResult(content=[TextContent(text="abandoned")])
server = Server("abandoner", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="abandoned")]))
assert callback_cancelled.is_set()
@requirement("protocol:cancel:late-response-ignored")
async def test_a_response_for_an_unknown_request_id_is_ignored() -> None:
"""A response whose id matches no in-flight request is ignored, as the spec asks.
The spec says a sender SHOULD ignore a response that arrives after it issued a cancellation;
that is the same client-side code path as any response with an unknown id, and that form is
deterministic to test without a client-side cancellation API.
"Ignored" is proved in two halves: the pong round-trip proves the read loop survived the
fabricated response (the ordered in-memory stream routed it first), and `surfaced` holding
only the control notification proves the fabricated response was never delivered to
`message_handler` (v1 surfaced it there as a RuntimeError).
A real Server cannot be made to answer with a fabricated id, so the test plays the server's
side of the wire by hand. Reserve this pattern for behaviour no real server can produce. The
other tests in this file run over the transport matrix; this one is in-memory only because the
scripted-peer mechanism is the in-memory stream pair, not because the behaviour is
transport-specific.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
def respond(request_id: types.RequestId, result: types.Result) -> SessionMessage:
return SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request_id,
# Serialized exactly as a real server serializes results onto the wire.
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
assert init.message.method == "initialize"
await server_write.send(
respond(
init.message.id,
InitializeResult(
protocol_version="2025-11-25",
capabilities=ServerCapabilities(),
server_info=Implementation(name="scripted", version="0.0.1"),
),
)
)
initialized = await server_read.receive()
assert isinstance(initialized, SessionMessage)
assert isinstance(initialized.message, JSONRPCNotification)
assert initialized.message.method == "notifications/initialized"
ping = await server_read.receive()
assert isinstance(ping, SessionMessage)
assert isinstance(ping.message, JSONRPCRequest)
assert ping.message.method == "ping"
# First a fabricated id that matches nothing in flight, then a control notification that
# is surfaced to message_handler (proving the handler is live), then the real id.
await server_write.send(respond(9999, EmptyResult()))
await server_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await server_write.send(respond(ping.message.id, EmptyResult()))
surfaced: list[IncomingMessage] = []
async def message_handler(message: IncomingMessage) -> None:
surfaced.append(message)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
ClientSession(client_read, client_write, message_handler=message_handler) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
await session.initialize()
pong = await session.send_request(PingRequest(), EmptyResult)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so the fabricated response was routed before the control
# notification: only the control surfaced, so the unknown-id response was dropped.
assert surfaced == snapshot([types.ToolListChangedNotification()])
@requirement("protocol:cancel:initialize-not-cancellable")
async def test_timed_out_initialize_sends_no_cancellation() -> None:
"""An abandoned initialize is not followed by notifications/cancelled on the wire (spec-mandated).
A real Server always answers initialize, so the test plays a stalling server by hand.
"""
received_methods: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
# Hold the initialize request unanswered until the client's read timeout fires.
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
received_methods.append(init.message.method)
follow_up = await server_read.receive()
assert isinstance(follow_up, SessionMessage)
assert isinstance(follow_up.message, JSONRPCRequest)
received_methods.append(follow_up.message.method)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=follow_up.message.id,
result=EmptyResult().model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
# The session-level read timeout is the only public pathway that abandons initialize.
ClientSession(client_read, client_write, read_timeout_seconds=0.000001) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info:
await session.initialize()
assert exc_info.value.error.code == REQUEST_TIMEOUT
# Override the session-level timeout: this ping must round-trip normally.
pong = await session.send_request(PingRequest(), EmptyResult, request_read_timeout_seconds=5)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so a courtesy cancel would have arrived ahead of the ping.
assert received_methods == snapshot(["initialize", "ping"])
@requirement("protocol:cancel:abort-signal")
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
the server-side handler is interrupted, and the session serves later requests normally.
Spec-mandated (cancellation flow): the sender cancels requests it abandons; the wire spelling
is per-transport (frame on stream wires, response-stream close at 2026 streamable HTTP).
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if 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
assert params.name == "echo"
return CallToolResult(content=[TextContent(text="ok")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")])
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) 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
assert abandon.cancelled_caught
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 abandoned call's late error response (sent on the legacy arms) arrive and be
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("protocol:cancel:abort-scoped")
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
abandoning the first interrupts only its handler and the second returns its result.
Steps:
1. `doomed` and `survivor` are both mid-flight (each handler has started).
2. The client abandons `doomed`; its handler observes cancellation.
3. `survivor` is released and completes normally.
"""
doomed_started = anyio.Event()
doomed_cancelled = anyio.Event()
survivor_started = anyio.Event()
release_survivor = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "doomed":
doomed_started.set()
try:
await anyio.Event().wait() # parked until the client's abandonment cancels it
except anyio.get_cancelled_exc_class():
doomed_cancelled.set()
raise
assert params.name == "survivor"
survivor_started.set()
with anyio.fail_after(5):
await release_survivor.wait()
return CallToolResult(content=[TextContent(text="survived")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("doomed", "survivor")]
)
server = Server("pair", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
abandon = anyio.CancelScope()
results: list[CallToolResult] = []
async def doomed_call() -> None:
with abandon:
await client.call_tool("doomed", {})
raise NotImplementedError # unreachable: the call never resolves
async def survivor_call() -> None:
results.append(await client.call_tool("survivor", {}))
async with anyio.create_task_group() as tg:
tg.start_soon(doomed_call)
with anyio.fail_after(5):
await doomed_started.wait()
tg.start_soon(survivor_call)
with anyio.fail_after(5):
await survivor_started.wait()
abandon.cancel()
with anyio.fail_after(5):
await doomed_cancelled.wait()
release_survivor.set()
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])])