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

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