Stop answering cancelled requests (#3188)

This commit is contained in:
Max 2026-07-27 23:26:00 +01:00 • committed by GitHub
commit 923341c98a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 464 additions and 204 deletions

View file

@ -537,14 +537,16 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
behavior=(
"A cancellation notification for an in-flight request stops the server-side handler, and the "
"receiver does not send a response for the cancelled request."
"receiver does not send a response for the cancelled request - no result and no error."
),
divergence=Divergence(
note=(
"The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled "
"request; both seats send an error response (code 0, 'Request cancelled') instead — the "
"server for cancelled client requests, and the client for cancelled server-initiated "
"requests — which is what unblocks the sender's pending call."
"The 2025-era streamable HTTP transport still answers a cancelled request, with a "
"REQUEST_CANCELLED (-32800) error - deliberate and era-scoped: that wire ends a request's "
"stream only with a response for its id, so silence would leave the POST (and any "
"resuming client's replay) open. Every other transport sends nothing, and the "
"2026-07-28 MUST NOT applies only there. Retires with the legacy transport; see "
"transport:streamable-http:cancelled-request-terminated."
),
),
arm_exclusions=(
@ -2559,6 +2561,22 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.",
),
"transport:streamable-http:cancelled-request-terminated": Requirement(
source="sdk",
behavior=(
"A request cancelled through notifications/cancelled is terminated with a REQUEST_CANCELLED "
"(-32800) error response, completing its POST - the JSON body in JSON-response mode, the "
"final event of its stream in SSE mode."
),
transports=("streamable-http",),
note=(
"An SDK choice, not spec-mandated (the spec-side gap is the Divergence on "
"protocol:cancel:in-flight): this era's wire ends a request's stream only with a response "
"for its id, and stores it so a resuming client's replay terminates too. The terminator is "
"written through the same ordered channel as the request's other messages, so it cannot "
"overtake anything already queued for the request."
),
),
"transport:streamable-http:stateless": Requirement(
source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
behavior=(

View file

@ -28,9 +28,10 @@ from mcp_types import (
Tool,
)
from mcp import MCPError
from mcp import Client, MCPError
from mcp.client import ClientRequestContext, ClientSession, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.streamable_http import REQUEST_CANCELLED
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests._stamp import Unstamp
@ -39,20 +40,39 @@ from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
_LEGACY_HTTP_TERMINATOR = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled")
"""The one wire where a cancelled request is still answered: the 2025-era streamable HTTP
transport ends a request only with a response, so it terminates the settled request with
`REQUEST_CANCELLED`. Every other transport sends nothing at all."""
async def _await_doomed_call(client: Client, outcomes: list[object]) -> None:
"""Await the doomed `block` call and record whatever, if anything, the caller receives.
On the stream transports nothing ever arrives, so this parks until the task is abandoned;
over legacy streamable HTTP the transport's terminator arrives as an MCPError.
"""
try:
outcomes.append(await client.call_tool("block", {}))
except MCPError as exc:
outcomes.append(exc.error)
@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.
"""Cancelling an in-flight request interrupts its handler, and the server sends no response for it.
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.
The cancellation is scripted by hand while a sibling task still awaits the call, which is
something a well-behaved sender never does (per spec it stops waiting once it cancels). That
lets the test prove the negative: after the handler is interrupted and the connection has
quiesced, no server response has reached the still-parked call - except the legacy
streamable HTTP terminator (`_LEGACY_HTTP_TERMINATOR`).
"""
started = anyio.Event()
handler_cancelled = anyio.Event()
request_ids: list[types.RequestId] = []
errors: list[ErrorData] = []
outcomes: list[object] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
@ -70,30 +90,26 @@ async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None:
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)
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(_await_doomed_call, client, outcomes)
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")])
await handler_cancelled.wait()
# Let anything the server was going to send be delivered before checking.
await anyio.wait_all_tasks_blocked()
assert outcomes in ([], [_LEGACY_HTTP_TERMINATOR])
task_group.cancel_scope.cancel() # abandon the call if it is still parked
@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()
handler_cancelled = anyio.Event()
request_ids: list[types.RequestId] = []
async def list_tools(
@ -112,7 +128,11 @@ async def test_session_serves_requests_after_cancellation(connect: Connect) -> N
assert ctx.request_id is not None
request_ids.append(ctx.request_id)
started.set()
await anyio.Event().wait() # blocks until cancelled
try:
await anyio.Event().wait() # blocks until cancelled
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)
@ -120,16 +140,13 @@ async def test_session_serves_requests_after_cancellation(connect: Connect) -> N
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)
task_group.start_soon(_await_doomed_call, client, list[object]())
await started.wait()
await client.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0]))
)
await handler_cancelled.wait()
task_group.cancel_scope.cancel() # abandon the parked call
result = await client.call_tool("echo", {})
@ -393,8 +410,9 @@ async def test_abandoning_a_call_stops_the_server_handler(connect: Connect, unst
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.
# Let anything still owed the abandoned call (the REQUEST_CANCELLED terminator over
# legacy streamable HTTP; nothing elsewhere) 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 unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@ -460,8 +478,9 @@ async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Con
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.
# Let anything still owed the abandoned call (the REQUEST_CANCELLED terminator over
# legacy streamable HTTP; nothing elsewhere) arrive and be dropped while the client is
# still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
assert [unstamped(result) for result in results] == snapshot(

View file

@ -353,8 +353,8 @@ async def test_abandoning_a_call_on_a_modern_stream_wire_sends_one_cancelled_fra
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.
# Let any in-flight delivery for the abandoned call settle while the client is still
# open, so teardown never races it (nothing arrives here: a cancelled request is not answered).
await anyio.wait_all_tasks_blocked()
call, cancel = [message.message for message in recording.sent]

View file

@ -12,22 +12,37 @@ import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CallToolRequestParams,
CallToolResult,
ElicitRequestParams,
ElicitResult,
ErrorData,
JSONRPCError,
JSONRPCMessage,
JSONRPCRequest,
LoggingMessageNotification,
LoggingMessageNotificationParams,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
jsonrpc_message_adapter,
)
from pydantic import BaseModel
from mcp.client import ClientRequestContext, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.streamable_http import REQUEST_CANCELLED
from mcp.shared.exceptions import MCPError
from tests.interaction._connect import connect_over_streamable_http
from tests.interaction._connect import (
base_headers,
connect_over_streamable_http,
initialize_body,
initialize_via_http,
mounted_app,
post_jsonrpc,
)
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@ -168,3 +183,80 @@ async def test_server_initiated_elicitation_round_trips_during_a_tool_call() ->
CallToolResult(content=[TextContent(text="confirmed=True")], structured_content={"result": "confirmed=True"})
)
assert [params.message for params in asked] == snapshot(["Proceed?"])
@requirement("transport:streamable-http:cancelled-request-terminated")
@pytest.mark.parametrize("json_response", [True, False], ids=["json-response", "sse-response"])
async def test_cancelled_request_is_terminated_with_request_cancelled(json_response: bool) -> None:
"""A cancelled request's POST completes carrying the `REQUEST_CANCELLED` terminal error.
The 2025-era wire ends a request only with a response, so this transport answers the
settled-unanswered request with `REQUEST_CANCELLED` (the dispatcher writes nothing on the
other transports). Driven with raw httpx2 because the observable is the HTTP exchange
itself, which a Client abandoning its own call would tear down first.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
call_request_id = 2
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
handler_started.set()
try:
await anyio.sleep_forever()
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable: only cancellation ends the sleep
server = Server("blocker", on_call_tool=call_tool)
call_body = JSONRPCRequest(
jsonrpc="2.0",
id=call_request_id,
method="tools/call",
params=CallToolRequestParams(name="block", arguments={}).model_dump(by_alias=True, mode="json"),
).model_dump(by_alias=True, exclude_none=True)
cancel_body = {
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {"requestId": call_request_id},
}
call_answers: list[JSONRPCMessage] = []
async with mounted_app(server, json_response=json_response) as (http, _manager):
if json_response:
# The SSE-reading handshake helper does not apply: JSON mode answers initialize with JSON.
initialized = await http.post("/mcp", json=initialize_body(), headers=base_headers())
session_id = initialized.headers["mcp-session-id"]
ready = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=base_headers(session_id=session_id),
)
assert ready.status_code == 202
else:
session_id = await initialize_via_http(http)
async def post_call() -> None:
if json_response:
response = await http.post("/mcp", json=call_body, headers=base_headers(session_id=session_id))
call_answers.append(jsonrpc_message_adapter.validate_json(response.content))
else:
_, messages = await post_jsonrpc(http, call_body, session_id=session_id)
call_answers.extend(messages)
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
task_group.start_soon(post_call)
await handler_started.wait()
cancelled = await http.post("/mcp", json=cancel_body, headers=base_headers(session_id=session_id))
assert cancelled.status_code == 202
await handler_cancelled.wait()
# The call's POST must now complete on its own; the task group waits for it.
assert call_answers == [
JSONRPCError(
jsonrpc="2.0",
id=call_request_id,
error=ErrorData(code=REQUEST_CANCELLED, message="Request cancelled"),
)
]

View file

@ -22,7 +22,6 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
@ -33,6 +32,7 @@ async def test_server_remains_functional_after_cancel():
# Track tool calls
call_count = 0
ev_first_call = anyio.Event()
ev_first_call_cancelled = anyio.Event()
first_request_id = None
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
@ -53,38 +53,45 @@ async def test_server_remains_functional_after_cancel():
if call_count == 1:
first_request_id = ctx.request_id
ev_first_call.set()
await anyio.sleep(5) # First call is slow
try:
await anyio.sleep_forever() # First call blocks until cancelled
except anyio.get_cancelled_exc_class():
ev_first_call_cancelled.set()
raise
return CallToolResult(content=[TextContent(type="text", text=f"Call number: {call_count}")])
raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover
server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
async with Client(server, mode="legacy") as client:
# First request (will be cancelled)
# First request (will be cancelled server-side, then abandoned here: a
# cancelled request is never answered, so nothing would wake this call)
async def first_request():
try:
await client.session.send_request(
CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})),
CallToolResult,
)
pytest.fail("First request should have been cancelled") # pragma: no cover
except MCPError:
pass # Expected
await client.session.send_request(
CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})),
CallToolResult,
)
raise NotImplementedError # unreachable: the task is cancelled before any answer
# Start first request
async with anyio.create_task_group() as tg:
tg.start_soon(first_request)
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
tg.start_soon(first_request)
# Wait for it to start
await ev_first_call.wait()
# Wait for it to start
await ev_first_call.wait()
# Cancel it
assert first_request_id is not None
await client.session.send_notification(
CancelledNotification(
params=CancelledNotificationParams(request_id=first_request_id, reason="Testing server recovery"),
# Cancel it
assert first_request_id is not None
await client.session.send_notification(
CancelledNotification(
params=CancelledNotificationParams(
request_id=first_request_id, reason="Testing server recovery"
),
)
)
)
await ev_first_call_cancelled.wait()
tg.cancel_scope.cancel() # abandon the parked call
# Second request (should work normally)
result = await client.call_tool("test_tool", {})

View file

@ -22,6 +22,7 @@ from mcp_types import (
CancelledNotificationParams,
ErrorData,
JSONRPCError,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
@ -34,10 +35,11 @@ from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream
from mcp.shared.dispatcher import CallOptions, DispatchContext, coerce_request_id
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnRequest, coerce_request_id
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage]
JSONRPCDispatcher,
PeerCancelMode,
_OutboundPlan,
_Pending,
_plan_outbound,
@ -122,113 +124,171 @@ async def test_handler_raising_exception_sends_code_zero_with_str_message():
assert exc.value.__cause__ is None # cause does not survive the wire
@pytest.mark.anyio
async def test_peer_cancel_interrupt_mode_writes_cancelled_error_response():
"""Matches the existing server: a peer-cancelled request is answered with code=0."""
async def _drive_cancelled_request(
on_request: OnRequest, *, peer_cancel_mode: PeerCancelMode = "interrupt", done: anyio.Event
) -> tuple[list[JSONRPCMessage], list[RequestId]]:
"""Send request 1, cancel it, then send an uncancelled control request 2.
Returns (answers written, ids settled unanswered). The control request proves the write
path is live, so an empty answer for id 1 is the suppression under test. Both requests
carry an `on_request_unanswered` callback; only cancelled request 1 should fire it.
Server-only, since the cancelled request is never answered. `done` is set by the
handler once request 1 reaches the state under test.
"""
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)
recording = RecordingWriteStream()
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
c2s_recv, recording, peer_cancel_mode=peer_cancel_mode
)
handler_started = anyio.Event()
unanswered: list[RequestId] = []
async def on_request_with_control(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
if method == "control":
return {"control": True}
handler_started.set()
return await on_request(ctx, method, params)
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
pass # the cancelled notification is teed here; nothing to observe
def request(request_id: RequestId, method: str) -> SessionMessage:
async def on_unanswered() -> None:
unanswered.append(request_id)
return SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=None),
metadata=ServerMessageMetadata(on_request_unanswered=on_unanswered),
)
cancel = JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
try:
async with anyio.create_task_group() as tg:
await tg.start(server.run, on_request_with_control, on_notify)
await c2s_send.send(request(1, "t"))
with anyio.fail_after(5):
await handler_started.wait()
await c2s_send.send(SessionMessage(message=cancel))
await done.wait()
await c2s_send.send(request(2, "control"))
# Quiesce: let both handler tasks run to the end of `_handle_request`,
# so every write they were going to make has been recorded.
await anyio.wait_all_tasks_blocked()
tg.cancel_scope.cancel()
finally:
c2s_send.close()
c2s_recv.close()
return [m.message for m in recording.sent], unanswered
_CONTROL_ONLY: list[JSONRPCMessage] = [JSONRPCResponse(jsonrpc="2.0", id=2, result={"control": True})]
"""Everything on the wire after a cancelled request 1: only the uncancelled control request 2 is answered."""
@pytest.mark.anyio
async def test_peer_cancel_interrupt_mode_interrupts_handler_and_writes_no_response():
"""Spec MUST NOT: a cancelled request is never answered - not even with an error - and settles unanswered."""
handler_exited = anyio.Event()
seen_ctx: list[DCtx] = []
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
seen_ctx.append(ctx)
handler_started.set()
try:
await anyio.sleep_forever()
finally:
handler_exited.set()
raise NotImplementedError
seen_error: list[ErrorData] = []
async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_):
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def call_then_record() -> None:
with pytest.raises(MCPError) as exc:
await client.send_raw_request("slow", None)
seen_error.append(exc.value.error)
tg.start_soon(call_then_record)
await handler_started.wait()
await client.notify("notifications/cancelled", {"requestId": 1})
await handler_exited.wait()
assert await _drive_cancelled_request(on_request, done=handler_exited) == (_CONTROL_ONLY, [1])
assert seen_ctx[0].cancel_requested.is_set()
assert seen_error == [ErrorData(code=0, message="Request cancelled")]
@pytest.mark.anyio
async def test_peer_cancel_landing_after_handlers_last_checkpoint_writes_only_the_result():
"""A peer cancel that fails to interrupt the handler writes only the result: one answer per
id goes on the wire (SDK-defined). The recording stream is needed because a memory stream's
`send` checkpoints, letting the deferred cancellation land mid-write and hide a double answer."""
async def test_peer_cancel_signal_mode_sets_event_and_drops_the_completed_handlers_result():
"""`"signal"` mode lets the handler run to completion, but the cancelled request is still not answered."""
cancel_seen = anyio.Event()
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
await ctx.cancel_requested.wait()
cancel_seen.set()
return {"finished": True}
assert await _drive_cancelled_request(on_request, peer_cancel_mode="signal", done=cancel_seen) == (
_CONTROL_ONLY,
[1],
)
@pytest.mark.anyio
@pytest.mark.parametrize(
"handler_failure",
[RuntimeError("cleanup failed"), MCPError(code=INTERNAL_ERROR, message="cleanup failed")],
ids=["unmapped-exception", "mcp-error"],
)
async def test_peer_cancel_drops_the_error_of_a_handler_that_fails_after_cancel(handler_failure: Exception):
"""A handler that turns its cancellation into an exception - mapped or not - writes no error response either."""
handler_failed = anyio.Event()
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
try:
await anyio.sleep_forever()
except anyio.get_cancelled_exc_class():
handler_failed.set()
raise handler_failure from None
raise NotImplementedError
assert await _drive_cancelled_request(on_request, done=handler_failed) == (_CONTROL_ONLY, [1])
@pytest.mark.anyio
@pytest.mark.parametrize(
"hook_error",
[RuntimeError("hook failed"), anyio.ClosedResourceError()],
ids=["hook-bug", "connection-closing"],
)
async def test_a_raising_unanswered_hook_is_contained(hook_error: Exception):
"""A transport `on_request_unanswered` hook that raises is contained, not fatal: the
dispatcher keeps serving (the control request written after it is still answered)."""
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)
recording = RecordingWriteStream()
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording)
handler_started = anyio.Event()
handler_exited = anyio.Event()
async def failing_hook() -> None:
raise hook_error
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
handler_started.set()
await ctx.cancel_requested.wait()
return {"completed": "after-cancel"}
if method == "control":
return {"control": True}
try:
await anyio.sleep_forever()
finally:
handler_exited.set()
raise NotImplementedError
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
pass # the cancelled notification is teed here; nothing to observe
pass
request_1 = SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None),
metadata=ServerMessageMetadata(on_request_unanswered=failing_hook),
)
cancel = JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
control = SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="control", params=None))
try:
async with anyio.create_task_group() as tg:
await tg.start(server.run, on_request, on_notify)
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None)))
with anyio.fail_after(5):
await handler_started.wait()
# The cancel is also the handler's wakeup, so anyio defers it and the handler completes.
await c2s_send.send(
SessionMessage(
message=JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}
)
)
)
# Quiesce: the handler has resumed, completed, and exited its scope.
await c2s_send.send(request_1)
await c2s_send.send(SessionMessage(message=cancel))
await handler_exited.wait()
await c2s_send.send(control)
await anyio.wait_all_tasks_blocked()
tg.cancel_scope.cancel()
finally:
c2s_send.close()
c2s_recv.close()
assert [m.message for m in recording.sent] == [
JSONRPCResponse(jsonrpc="2.0", id=1, result={"completed": "after-cancel"})
]
@pytest.mark.anyio
async def test_peer_cancel_signal_mode_sets_event_but_handler_runs_to_completion():
handler_started = anyio.Event()
cancel_seen = anyio.Event()
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
handler_started.set()
await ctx.cancel_requested.wait()
cancel_seen.set()
return {"finished": True}
def factory(*, can_send_request: bool = True):
client, server, close = jsonrpc_pair(can_send_request=can_send_request)
assert isinstance(server, JSONRPCDispatcher)
server._peer_cancel_mode = "signal" # pyright: ignore[reportPrivateUsage]
return client, server, close
result_box: list[dict[str, Any]] = []
async with running_pair(factory, server_on_request=server_on_request) as (client, *_):
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
result_box.append(await client.send_raw_request("slow", None))
tg.start_soon(call)
await handler_started.wait()
await client.notify("notifications/cancelled", {"requestId": 1})
await cancel_seen.wait()
assert result_box == [{"finished": True}]
assert [m.message for m in recording.sent] == _CONTROL_ONLY
@pytest.mark.anyio
@ -1592,14 +1652,16 @@ async def test_cancelled_notification_for_in_flight_request_is_teed_to_on_notify
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
with pytest.raises(MCPError):
await client.send_raw_request("slow", None)
# Never answered; abandoned below without a courtesy cancel so `srec` sees only the peer's.
await client.send_raw_request("slow", None, {"cancel_on_abandon": False})
raise NotImplementedError # unreachable: the task is cancelled first
tg.start_soon(call)
await handler_started.wait()
await client.notify("notifications/cancelled", {"requestId": 1})
await handler_exited.wait()
await srec.notified.wait()
tg.cancel_scope.cancel() # abandon the parked call
assert srec.notifications == [("notifications/cancelled", {"requestId": 1})]
@ -2057,8 +2119,8 @@ async def test_cancelled_with_bool_request_id_does_not_cancel_request_one():
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
with pytest.raises(MCPError):
await client.send_raw_request("slow", None)
await client.send_raw_request("slow", None) # never answered; abandoned below
raise NotImplementedError # unreachable: the task is cancelled first
tg.start_soon(call)
await handler_started.wait()
@ -2068,6 +2130,7 @@ async def test_cancelled_with_bool_request_id_does_not_cancel_request_one():
assert not handler_exited.is_set()
await client.notify("notifications/cancelled", {"requestId": 1})
await handler_exited.wait()
tg.cancel_scope.cancel() # abandon the parked call
@pytest.mark.anyio
@ -2160,11 +2223,15 @@ async def test_request_with_bool_meta_progress_token_is_not_adopted():
async def test_cancelled_correlates_across_string_and_int_request_id_forms(request_id: RequestId, cancel_id: object):
"""A peer that stringifies the id between request and cancel still cancels (same `coerce_request_id` path)."""
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
recording = RecordingWriteStream()
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording)
handler_interrupted = anyio.Event()
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
await anyio.sleep_forever()
try:
await anyio.sleep_forever()
finally:
handler_interrupted.set()
raise NotImplementedError
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
@ -2184,15 +2251,12 @@ async def test_cancelled_correlates_across_string_and_int_request_id_forms(reque
)
)
with anyio.fail_after(5):
resp = await s2c_recv.receive()
assert isinstance(resp, SessionMessage)
assert isinstance(resp.message, JSONRPCError)
assert resp.message.id == request_id # response echoes the peer's id form verbatim
assert resp.message.error == ErrorData(code=0, message="Request cancelled")
await handler_interrupted.wait() # the cancel reached the handler despite the id form
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
s.close()
c2s_send.close()
c2s_recv.close()
assert recording.sent == [] # cancelled: no response, in either id form
@pytest.mark.anyio
@ -2245,11 +2309,7 @@ async def test_completed_handler_does_not_evict_reused_request_id_from_in_flight
)
)
)
resp2 = await s2c_recv.receive()
assert isinstance(resp2, SessionMessage)
assert isinstance(resp2.message, JSONRPCError)
assert resp2.message.error == ErrorData(code=0, message="Request cancelled")
assert second_exited.is_set()
await second_exited.wait() # the cancel reached the surviving entry
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
@ -2308,11 +2368,7 @@ async def test_duplicate_request_id_completion_of_first_handler_keeps_second_can
)
)
)
resp2 = await s2c_recv.receive()
assert isinstance(resp2, SessionMessage)
assert isinstance(resp2.message, JSONRPCError)
assert resp2.message.error == ErrorData(code=0, message="Request cancelled")
assert second_exited.is_set()
await second_exited.wait() # the cancel reached the surviving entry
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
@ -2366,11 +2422,12 @@ async def test_server_middleware_observes_cancelled_notification():
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
with pytest.raises(MCPError):
await client.session.send_request(
CallToolRequest(params=CallToolRequestParams(name="t", arguments={})),
CallToolResult,
)
# Never answered once cancelled; abandoned below.
await client.session.send_request(
CallToolRequest(params=CallToolRequestParams(name="t", arguments={})),
CallToolResult,
)
raise NotImplementedError # unreachable: the task is cancelled first
tg.start_soon(call)
await handler_started.wait()
@ -2381,10 +2438,8 @@ async def test_server_middleware_observes_cancelled_notification():
)
)
await cancel_observed.wait()
assert len(observed) == 1
assert observed[0][0] == "notifications/cancelled"
assert observed[0][1]["requestId"] == request_id
assert observed[0][1]["reason"] == "user clicked stop"
tg.cancel_scope.cancel() # abandon the parked call (sends its own courtesy cancel)
assert observed[0] == ("notifications/cancelled", {"requestId": request_id, "reason": "user clicked stop"})
@pytest.mark.anyio