Stop answering cancelled requests (#3188)
This commit is contained in:
parent
e8ef138153
commit
923341c98a
4 changed files with 464 additions and 204 deletions
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue