Skip to content

Commit 79c3ac3

Browse files
committed
Pin the abandonment-cancellation contract in the interaction suite
1 parent 69e6cd3 commit 79c3ac3

4 files changed

Lines changed: 353 additions & 10 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,20 @@ def __post_init__(self) -> None:
474474
"never reused within the session."
475475
),
476476
),
477+
"protocol:request-id:caller-supplied": Requirement(
478+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#receiving-notifications",
479+
behavior=(
480+
"A caller can supply the id of a request it sends, so the id is known before any response "
481+
"arrives; subscriptions/listen streams are demultiplexed by exactly that id."
482+
),
483+
added_in="2026-07-28",
484+
deferred=(
485+
"No public API surface yet: the capability exists at the dispatcher seam "
486+
"(CallOptions['request_id'], unit-tested there), but ClientSession.send_request does not "
487+
"expose it. The public consumer arrives with the client-side listen driver (Client.listen), "
488+
"whose interaction tests will exercise it end to end."
489+
),
490+
),
477491
"protocol:notifications:no-response": Requirement(
478492
source=f"{SPEC_BASE_URL}/basic#notifications",
479493
behavior=(
@@ -484,14 +498,29 @@ def __post_init__(self) -> None:
484498
"protocol:cancel:abort-signal": Requirement(
485499
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow",
486500
behavior=(
487-
"Cancelling an in-flight request through the client API sends notifications/cancelled with "
488-
"the request id and fails the local call."
501+
"Abandoning an in-flight request client-side (cancelling the task awaiting it) cancels the "
502+
"request itself: the server-side handler stops and the session serves later requests "
503+
"normally. Each transport carries the signal in its own spelling - a cancelled frame on "
504+
"stream wires, closing the request's own response stream at 2026-07-28 streamable HTTP."
489505
),
490-
deferred=(
491-
"Not implemented in the SDK: there is no public client-side API to cancel an in-flight "
492-
"request; cancellation requires hand-constructing the notification (which is how "
493-
"protocol:cancel:in-flight exercises the receiving side)."
506+
arm_exclusions=(
507+
ArmExclusion(
508+
reason="requires-session",
509+
transport="streamable-http-stateless",
510+
note=(
511+
"The 2025-era cancel frame POSTs on a fresh per-request transport that shares no "
512+
"in-flight state with the blocked request, so the handler is never interrupted."
513+
),
514+
),
515+
),
516+
),
517+
"protocol:cancel:abort-scoped": Requirement(
518+
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
519+
behavior=(
520+
"Abandoning one in-flight request cancels only that request: a concurrent request on the "
521+
"same connection keeps running and returns its result."
494522
),
523+
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
495524
),
496525
"protocol:cancel:handler-abort-propagates": Requirement(
497526
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
@@ -550,6 +579,16 @@ def __post_init__(self) -> None:
550579
ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"),
551580
),
552581
),
582+
"protocol:cancel:stream-frame": Requirement(
583+
source=f"{SPEC_2026_BASE_URL}/basic/utilities/cancellation#transport-specific-cancellation",
584+
behavior=(
585+
"On stream (stdio-shaped) wires at 2026-07-28, abandoning an in-flight request sends exactly "
586+
"one notifications/cancelled naming its request id - streams keep the frame spelling of "
587+
"cancellation that streamable HTTP dropped."
588+
),
589+
added_in="2026-07-28",
590+
note="Exercised over the in-memory stream pair, the same dual-era wire stdio serves.",
591+
),
553592
"protocol:cancel:unknown-id-ignored": Requirement(
554593
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling",
555594
behavior=(
@@ -3256,6 +3295,30 @@ def __post_init__(self) -> None:
32563295
transports=("streamable-http",),
32573296
note="Only observable over HTTP: Accept is an HTTP request header.",
32583297
),
3298+
"client-transport:http:cancel-closes-stream": Requirement(
3299+
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#cancellation",
3300+
behavior=(
3301+
"At 2026-07-28, abandoning an in-flight request closes that request's own POST stream and "
3302+
"posts nothing further: no notifications/cancelled reaches the server (the revision defines "
3303+
"no client-to-server notifications), and the server treats the disconnect as cancellation "
3304+
"of exactly that request."
3305+
),
3306+
transports=("streamable-http",),
3307+
added_in="2026-07-28",
3308+
supersedes=("client-transport:http:cancel-posts-frame",),
3309+
note="HTTP-only by nature: the response stream that closing constitutes the signal is an HTTP exchange.",
3310+
),
3311+
"client-transport:http:cancel-posts-frame": Requirement(
3312+
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow",
3313+
behavior=(
3314+
"At 2025-era revisions, abandoning an in-flight request POSTs exactly one "
3315+
"notifications/cancelled naming its request id."
3316+
),
3317+
transports=("streamable-http",),
3318+
removed_in="2026-07-28",
3319+
superseded_by="client-transport:http:cancel-closes-stream",
3320+
note="HTTP-only by nature: pins that the frame travels as its own POST on the legacy HTTP wire.",
3321+
),
32593322
"client-transport:http:concurrent-streams": Requirement(
32603323
source="sdk",
32613324
behavior="Multiple concurrent POST-initiated SSE streams each deliver their response to the right caller.",

tests/interaction/lowlevel/test_cancellation.py

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Cancellation interactions against the low-level Server, driven through the public Client API.
22
3-
There is no client-side cancellation API: cancelling means sending a CancelledNotification
4-
carrying the request id, which only the server-side handler can observe (`ctx.request_id`), so
5-
these tests capture the id from inside the blocked handler before cancelling. The handler blocks
6-
on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
3+
Client-side, cancelling means abandoning: cancelling the task that awaits a call makes the SDK
4+
carry the signal in the transport's own spelling (a cancelled frame on stream wires, closing the
5+
request's own response stream at 2026-07-28 streamable HTTP). The receiving-side tests instead
6+
script a CancelledNotification by hand, capturing the request id from inside the blocked handler.
7+
Handlers block on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`.
78
"""
89

910
import anyio
@@ -20,9 +21,11 @@
2021
JSONRPCNotification,
2122
JSONRPCRequest,
2223
JSONRPCResponse,
24+
ListToolsResult,
2325
PingRequest,
2426
ServerCapabilities,
2527
TextContent,
28+
Tool,
2629
)
2730

2831
from mcp import MCPError
@@ -344,3 +347,121 @@ async def scripted_server(streams: MessageStream) -> None:
344347
assert pong == snapshot(EmptyResult())
345348
# The stream is ordered, so a courtesy cancel would have arrived ahead of the ping.
346349
assert received_methods == snapshot(["initialize", "ping"])
350+
351+
352+
@requirement("protocol:cancel:abort-signal")
353+
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
354+
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
355+
the server-side handler is interrupted, and the session serves later requests normally.
356+
357+
Spec-mandated (cancellation flow): the sender cancels requests it abandons; the wire spelling
358+
is per-transport (frame on stream wires, response-stream close at 2026 streamable HTTP).
359+
"""
360+
handler_started = anyio.Event()
361+
handler_cancelled = anyio.Event()
362+
363+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
364+
if params.name == "block":
365+
handler_started.set()
366+
try:
367+
await anyio.Event().wait() # parked until the client's abandonment cancels it
368+
except anyio.get_cancelled_exc_class():
369+
handler_cancelled.set()
370+
raise
371+
assert params.name == "echo"
372+
return CallToolResult(content=[TextContent(text="ok")])
373+
374+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
375+
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")])
376+
377+
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
378+
379+
async with connect(server) as client:
380+
abandon = anyio.CancelScope()
381+
382+
async def call_and_abandon() -> None:
383+
with abandon:
384+
await client.call_tool("block", {})
385+
raise NotImplementedError # unreachable: the call never resolves
386+
assert abandon.cancelled_caught
387+
388+
async with anyio.create_task_group() as tg:
389+
tg.start_soon(call_and_abandon)
390+
with anyio.fail_after(5):
391+
await handler_started.wait()
392+
abandon.cancel()
393+
with anyio.fail_after(5):
394+
await handler_cancelled.wait()
395+
396+
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
397+
# dropped while the client is still open, so teardown never races its delivery.
398+
await anyio.wait_all_tasks_blocked()
399+
result = await client.call_tool("echo", {})
400+
assert result.content == [TextContent(text="ok")]
401+
402+
403+
@requirement("protocol:cancel:abort-scoped")
404+
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
405+
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
406+
abandoning the first interrupts only its handler and the second returns its result.
407+
408+
Steps:
409+
1. `doomed` and `survivor` are both mid-flight (each handler has started).
410+
2. The client abandons `doomed`; its handler observes cancellation.
411+
3. `survivor` is released and completes normally.
412+
"""
413+
doomed_started = anyio.Event()
414+
doomed_cancelled = anyio.Event()
415+
survivor_started = anyio.Event()
416+
release_survivor = anyio.Event()
417+
418+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
419+
if params.name == "doomed":
420+
doomed_started.set()
421+
try:
422+
await anyio.Event().wait() # parked until the client's abandonment cancels it
423+
except anyio.get_cancelled_exc_class():
424+
doomed_cancelled.set()
425+
raise
426+
assert params.name == "survivor"
427+
survivor_started.set()
428+
with anyio.fail_after(5):
429+
await release_survivor.wait()
430+
return CallToolResult(content=[TextContent(text="survived")])
431+
432+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
433+
return ListToolsResult(
434+
tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("doomed", "survivor")]
435+
)
436+
437+
server = Server("pair", on_list_tools=list_tools, on_call_tool=call_tool)
438+
439+
async with connect(server) as client:
440+
abandon = anyio.CancelScope()
441+
results: list[CallToolResult] = []
442+
443+
async def doomed_call() -> None:
444+
with abandon:
445+
await client.call_tool("doomed", {})
446+
raise NotImplementedError # unreachable: the call never resolves
447+
448+
async def survivor_call() -> None:
449+
results.append(await client.call_tool("survivor", {}))
450+
451+
async with anyio.create_task_group() as tg:
452+
tg.start_soon(doomed_call)
453+
with anyio.fail_after(5):
454+
await doomed_started.wait()
455+
tg.start_soon(survivor_call)
456+
with anyio.fail_after(5):
457+
await survivor_started.wait()
458+
abandon.cancel()
459+
with anyio.fail_after(5):
460+
await doomed_cancelled.wait()
461+
release_survivor.set()
462+
463+
# Let the abandoned call's late error response (sent on the legacy arms) arrive and be
464+
# dropped while the client is still open, so teardown never races its delivery.
465+
await anyio.wait_all_tasks_blocked()
466+
467+
assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])])

tests/interaction/lowlevel/test_wire.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,58 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq
308308

309309
assert len(errors) == 1
310310
assert errors[0].code == INVALID_PARAMS
311+
312+
313+
@requirement("protocol:cancel:stream-frame")
314+
async def test_abandoning_a_call_on_a_modern_stream_wire_sends_one_cancelled_frame() -> None:
315+
"""At 2026-07-28 over a stream (stdio-shaped) wire, abandoning an in-flight call puts exactly
316+
one notifications/cancelled naming that request on the wire, and the frame interrupts the
317+
server-side handler - stream wires keep the frame spelling that 2026 streamable HTTP dropped.
318+
"""
319+
handler_started = anyio.Event()
320+
handler_cancelled = anyio.Event()
321+
322+
async def list_tools(
323+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
324+
) -> types.ListToolsResult:
325+
raise NotImplementedError # registered so tools/call is served; the stream wire never lists
326+
327+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
328+
assert params.name == "block"
329+
handler_started.set()
330+
try:
331+
await anyio.Event().wait() # parked until the client's abandonment cancels it
332+
except anyio.get_cancelled_exc_class():
333+
handler_cancelled.set()
334+
raise
335+
raise NotImplementedError # unreachable
336+
337+
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
338+
recording = RecordingTransport(InMemoryTransport(server))
339+
340+
async with Client(recording, mode="2026-07-28") as client:
341+
abandon = anyio.CancelScope()
342+
343+
async def call_and_abandon() -> None:
344+
with abandon:
345+
await client.call_tool("block", {})
346+
raise NotImplementedError # unreachable: the call never resolves
347+
348+
async with anyio.create_task_group() as tg:
349+
tg.start_soon(call_and_abandon)
350+
with anyio.fail_after(5):
351+
await handler_started.wait()
352+
abandon.cancel()
353+
with anyio.fail_after(5):
354+
await handler_cancelled.wait()
355+
356+
# Let the cancelled call's late error response arrive and be dropped while the client
357+
# is still open, so teardown never races its delivery.
358+
await anyio.wait_all_tasks_blocked()
359+
360+
call, cancel = [message.message for message in recording.sent]
361+
assert isinstance(call, JSONRPCRequest)
362+
assert call.method == "tools/call"
363+
assert isinstance(cancel, JSONRPCNotification)
364+
assert cancel.method == "notifications/cancelled"
365+
assert cancel.params == {"requestId": call.id, "reason": "caller cancelled"}

0 commit comments

Comments
 (0)