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
4 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"),
)
]