Skip to content

Commit 923341c

Browse files
authored
Stop answering cancelled requests (#3188)
1 parent e8ef138 commit 923341c

11 files changed

Lines changed: 464 additions & 204 deletions

File tree

docs/migration.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,6 +1584,14 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params
15841584

15851585
Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens.
15861586

1587+
### Cancelled requests are no longer answered
1588+
1589+
In v1, when the peer sent `notifications/cancelled` for an in-flight request, the receiving side interrupted the handler and answered the request anyway with a JSON-RPC error, `{"code": 0, "message": "Request cancelled"}` - and `0` is not a defined JSON-RPC error code. The 2026-07-28 transport specifications (stdio, streamable HTTP) say a server **MUST NOT** send any further messages for a cancelled request; the older cancellation pattern already said it **SHOULD NOT**. The sender is expected to stop waiting once it cancels, so that error response has been removed: a cancelled request now produces no response at all - no result, and no error - even if the handler runs to completion or fails afterwards. This applies to both seats (the server for cancelled client requests, and the client for cancelled server-initiated requests such as sampling or elicitation).
1590+
1591+
The one deliberate exception is the 2025-era streamable HTTP transport (`StreamableHTTPServerTransport`), whose wire can end a request's stream only with a response for that id (and stores that response so a resuming client's replay terminates too). Under the 2025 rule, a SHOULD NOT, that transport now terminates a cancelled request with a valid `-32800` error (`mcp.server.streamable_http.REQUEST_CANCELLED`, mirroring LSP's `RequestCancelled`) in place of the old `0`. Nothing else answers.
1592+
1593+
Nothing changes for callers of the built-in client: abandoning a call (cancelling the awaiting task, or a per-request timeout) never waited for that response. If you send `notifications/cancelled` by hand while still awaiting the call, the call now receives nothing on most transports (over 2025-era streamable HTTP it fails with `REQUEST_CANCELLED`); stop awaiting it yourself, or use a per-request timeout.
1594+
15871595
### `Server.run()` no longer takes a `stateless` flag
15881596

15891597
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
@@ -1933,9 +1941,9 @@ except MCPError as e:
19331941

19341942
Behavior changes:
19351943

1936-
- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves.
1944+
- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (no response is sent for the cancelled request). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves.
19371945
- **Notification routing is unchanged.** Each server notification is still delivered to its typed callback first — `logging_callback` for log messages, the per-request `progress_callback` whose `progressToken` matches a request you issued (`progress_callback=` still stamps `params._meta.progressToken` with the outbound request id) — and then teed to `message_handler`. `notifications/cancelled` is applied by the dispatcher and never surfaced, also as in v1.
1938-
- **Cancellation now reaches the server.** Cancelling the task or cancel scope awaiting a request (e.g. `anyio.move_on_after()` around `session.call_tool(...)`), or a request hitting its read timeout, now sends `notifications/cancelled` for that request, so the server interrupts the handler instead of leaving it running; v1 sent nothing ([#2507](https://github.com/modelcontextprotocol/python-sdk/issues/2507)). A test that pinned the v1 gap with a strict `xfail` now passes — drop the marker. The cancelled peer still answers with `ErrorData(code=0, message="Request cancelled")` as in v1 (discarded, since the caller's waiter is already gone). There is no public request-id or cancel handle (v1's private `session._request_id` went with `BaseSession`): cancel the awaiting task or scope and the dispatcher sends the cancel for you.
1946+
- **Cancellation now reaches the server.** Cancelling the task or cancel scope awaiting a request (e.g. `anyio.move_on_after()` around `session.call_tool(...)`), or a request hitting its read timeout, now sends `notifications/cancelled` for that request, so the server interrupts the handler instead of leaving it running; v1 sent nothing ([#2507](https://github.com/modelcontextprotocol/python-sdk/issues/2507)). A test that pinned the v1 gap with a strict `xfail` now passes — drop the marker. The cancelled peer no longer answers at all (v1 sent `ErrorData(code=0, message="Request cancelled")`); the one exception, the 2025-era streamable HTTP transport's `-32800` terminator, is discarded like the v1 error since the caller's waiter is already gone — see [Cancelled requests are no longer answered](#cancelled-requests-are-no-longer-answered). There is no public request-id or cancel handle (v1's private `session._request_id` went with `BaseSession`): cancel the awaiting task or scope and the dispatcher sends the cancel for you.
19391947
- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1.
19401948
- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works.
19411949
- **`send_notification` after the connection has closed is dropped with a debug log instead of raising.** In v1 the send raised `anyio.BrokenResourceError` (peer gone) or `anyio.ClosedResourceError` (session torn down), and this applied to the typed helpers (`send_roots_list_changed`, `send_progress_notification`) too. Code that used the exception as its disconnect signal should probe with a request instead (`send_request` still raises `MCPError` after close, see above) or scope the sending task to the session's lifetime.

examples/stories/streaming/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ uv run python -m stories.streaming.client --http --server server_lowlevel
5656
OpenTelemetry instead of `notifications/message`. It is shown here because
5757
servers still need to support 2025-era clients during that window. Progress
5858
and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta.
59-
- When a request is cancelled the server currently replies with
60-
`ErrorData(code=0, message="Request cancelled")`; the spec says it should not
61-
reply at all. The client never observes it (its awaiting task is already
62-
cancelled), so this story does not assert on the reply.
59+
- A cancelled request is not answered: no response follows
60+
`notifications/cancelled`. (The 2025-era streamable HTTP transport is the one
61+
exception - its wire ends a request only with a response, so it terminates
62+
with a `-32800` `REQUEST_CANCELLED` error.) The client never observes any of
63+
this - its awaiting task is already cancelled - so this story does not assert
64+
on it.
6365

6466
## Spec
6567

@@ -69,5 +71,6 @@ uv run python -m stories.streaming.client --http --server server_lowlevel
6971

7072
## See also
7173

72-
`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the
73-
cancellation error path), `tools/` (the basics this builds on).
74+
`parallel_calls/` (concurrent in-flight calls), `error_handling/` (error
75+
surfaces: `is_error` results vs protocol errors), `tools/` (the basics this
76+
builds on).

src/mcp/server/streamable_http.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@
6565
# whole session on a lazily-started `sse_writer`. See #1764.
6666
REQUEST_STREAM_BUFFER_SIZE: Final = 16
6767

68+
# Error code answering a request that settled without a response (e.g. it was
69+
# cancelled) on this 2025-era wire, which ends a request's stream only with a
70+
# response. Mirrors LSP's RequestCancelled; not sent by the 2026 transports, where
71+
# the spec forbids answering a cancelled request. See
72+
# `StreamableHTTPServerTransport._terminate_unanswered_request`.
73+
REQUEST_CANCELLED: Final = -32800
74+
6875
# Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E)
6976
# Pattern ensures entire string contains only valid characters by using ^ and $ anchors
7077
SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$")
@@ -252,7 +259,7 @@ def close_standalone_sse_stream(self) -> None:
252259

253260
def _create_session_message(
254261
self,
255-
message: JSONRPCMessage,
262+
message: JSONRPCRequest,
256263
request: Request,
257264
request_id: RequestId,
258265
protocol_version: str,
@@ -262,7 +269,10 @@ def _create_session_message(
262269
The close_sse_stream callbacks are only provided when the client supports
263270
resumability (protocol version >= 2025-11-25). Old clients can't resume if
264271
the stream is closed early because they didn't receive a priming event.
272+
Every request carries `on_request_unanswered`, so a request that settles
273+
without a response is still terminated on this era's wire.
265274
"""
275+
end_stream = partial(self._terminate_unanswered_request, message.id)
266276
# Only provide close callbacks when client supports resumability
267277
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):
268278

@@ -276,9 +286,10 @@ async def close_standalone_stream_callback() -> None:
276286
request_context=request,
277287
close_sse_stream=close_stream_callback,
278288
close_standalone_sse_stream=close_standalone_stream_callback,
289+
on_request_unanswered=end_stream,
279290
)
280291
else:
281-
metadata = ServerMessageMetadata(request_context=request)
292+
metadata = ServerMessageMetadata(request_context=request, on_request_unanswered=end_stream)
282293

283294
return SessionMessage(message, metadata=metadata)
284295

@@ -390,6 +401,20 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent:
390401

391402
return event_data
392403

404+
async def _terminate_unanswered_request(self, request_id: RequestId) -> None:
405+
"""Terminate a request that settled without a response (e.g. cancelled).
406+
407+
The 2025-era wire ends a request's stream only with a response for its
408+
id - and stores that response so a resuming client's replay terminates
409+
too - so this era answers a cancelled request with `REQUEST_CANCELLED`
410+
where the dispatcher itself stays silent (the 2026 transports MUST NOT
411+
answer). It is written through the same ordered channel as the request's
412+
other messages, so it cannot overtake anything already queued for it.
413+
"""
414+
assert self._write_stream is not None # a dispatched request implies connect() ran
415+
error = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled")
416+
await self._write_stream.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error)))
417+
393418
async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
394419
"""Clean up memory streams for a given request ID."""
395420
if request_id in self._request_streams: # pragma: no branch
@@ -555,7 +580,10 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
555580
)
556581
request_stream_reader = self._request_streams[request_id][1]
557582
# Process the message
558-
metadata = ServerMessageMetadata(request_context=request)
583+
metadata = ServerMessageMetadata(
584+
request_context=request,
585+
on_request_unanswered=partial(self._terminate_unanswered_request, message.id),
586+
)
559587
session_message = SessionMessage(message, metadata=metadata)
560588
await writer.send(session_message)
561589
try:

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@
8080

8181
PeerCancelMode = Literal["interrupt", "signal"]
8282
"""How `notifications/cancelled` is applied: `"interrupt"` (default) cancels
83-
the handler's scope; `"signal"` only sets `ctx.cancel_requested`."""
83+
the handler's scope; `"signal"` only sets `ctx.cancel_requested` and lets the
84+
handler run to completion. Either way the cancelled request is never
85+
answered - the handler's eventual result or error is dropped, not written."""
8486

8587

8688
def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
@@ -696,9 +698,13 @@ async def _handle_request(
696698
) -> None:
697699
"""Run `on_request` for one inbound request and write its response.
698700
699-
The single exception-to-wire boundary: handler exceptions become `JSONRPCError` here.
701+
The single exception-to-wire boundary: handler exceptions become
702+
`JSONRPCError` here. A request the peer cancelled is never answered
703+
(spec: MUST NOT send further messages for it) - it settles unanswered
704+
instead, and `_settle_unanswered` tells the transport.
700705
"""
701706
answer_write_started = False
707+
handler_failure: BaseException | None = None # re-raised once the request settles
702708
try:
703709
with scope:
704710
try:
@@ -711,27 +717,21 @@ async def _handle_request(
711717
key = coerce_request_id(req.id)
712718
if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx:
713719
del self._in_flight[key]
714-
# A write interrupted by cancellation may still have delivered
715-
# (a memory-stream send can hand its item to the receiver and
716-
# still raise), so a started answer write counts as sent below:
717-
# peers drop late responses, while a second answer for one id
718-
# would break JSON-RPC.
719-
answer_write_started = True
720-
await self._write_result(req.id, result)
721-
if scope.cancelled_caught:
722-
# anyio absorbs the scope's own cancel at __exit__, and
723-
# `cancelled_caught` (unlike `cancel_called`) guarantees the
724-
# result write above did not happen - no double response.
725-
# TODO(L38): spec says SHOULD NOT respond after cancel;
726-
# the existing server always has, so match that for now.
727-
answer_write_started = True
728-
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
720+
if not dctx.cancel_requested.is_set():
721+
# A write interrupted by cancellation may still have delivered
722+
# (a memory-stream send can hand its item to the receiver and
723+
# still raise), so a started answer write counts as sent below:
724+
# peers drop late responses, while a second answer for one id
725+
# would break JSON-RPC.
726+
answer_write_started = True
727+
await self._write_result(req.id, result)
729728
except anyio.get_cancelled_exc_class():
730729
# Shutdown: answer the request so the peer isn't left waiting - unless
731730
# an answer write already started (it may have reached the transport;
732-
# prefer possibly-zero answers over possibly-two). The shielded helper
733-
# is needed because bare awaits re-raise here.
734-
if not answer_write_started:
731+
# prefer possibly-zero answers over possibly-two), or the peer already
732+
# cancelled it and stopped waiting. The shielded helper is needed
733+
# because bare awaits re-raise here.
734+
if not answer_write_started and not dctx.cancel_requested.is_set():
735735
await self._final_write(
736736
partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")),
737737
shield=True,
@@ -741,15 +741,24 @@ async def _handle_request(
741741
raise
742742
except Exception as e:
743743
error = handler_exception_to_error_data(e)
744-
if error is not None:
745-
await self._write_error(req.id, error)
746-
else:
744+
if error is None:
747745
logger.exception("handler for %r raised", req.method)
748746
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
749747
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
750-
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
748+
error = ErrorData(code=0, message=str(e))
751749
if self._raise_handler_exceptions:
752-
raise
750+
handler_failure = e
751+
# A cancel silences only the wire; the failure stays as visible as before.
752+
if not dctx.cancel_requested.is_set():
753+
answer_write_started = True
754+
await self._write_error(req.id, error)
755+
# The one place a cancelled request settles: the handler is done (any
756+
# mode) with nothing written. A peer-interrupt cancel is absorbed at
757+
# scope __exit__ and lands here too.
758+
if not answer_write_started:
759+
await self._settle_unanswered(dctx)
760+
if handler_failure is not None:
761+
raise handler_failure
753762
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
754763

755764
def _allocate_id(self) -> int:
@@ -771,6 +780,23 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
771780
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
772781
logger.debug("dropped error for %r: write stream closed", request_id)
773782

783+
async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None:
784+
"""Run the transport's `on_request_unanswered` hook: this request settled with no response.
785+
786+
The dispatcher writes nothing for it; a transport whose wire must still
787+
end the request (2025-era streamable HTTP) does so from this hook. A
788+
raising hook is contained here, like the other callback boundaries.
789+
"""
790+
metadata = dctx.message_metadata
791+
if not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None:
792+
return
793+
try:
794+
await metadata.on_request_unanswered()
795+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
796+
logger.debug("on_request_unanswered dropped: connection closing")
797+
except Exception:
798+
logger.exception("on_request_unanswered hook raised")
799+
774800
async def _final_write(
775801
self,
776802
write: Callable[[], Awaitable[None]],

0 commit comments

Comments
 (0)