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
468 lines
20 KiB
Python
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")])])
|