Skip to content

Commit 21fadc7

Browse files
committed
Stop answering cancelled requests
When a peer sent notifications/cancelled for an in-flight request, the dispatcher interrupted the handler and then answered the request anyway with a synthesized JSON-RPC error, {"code": 0, "message": "Request cancelled"} - a v1 carry-over pinned as a spec divergence. The 2026-07-28 transport rules say a cancelled request MUST NOT be answered, and code 0 is not a valid JSON-RPC error code, so a cancelled request now settles with no response at all: no result if the handler runs to completion, no error if it fails. Both seats change (the server for cancelled client requests, the client for cancelled server-initiated ones). The error frame had been doing a second job on the 2025-era streamable HTTP wire, where each request's POST stays open until its response arrives: it was what let the POST complete. So the dispatcher now tells the transport when a request settles unanswered (an on_request_unanswered hook on the inbound message metadata), and the transport ends that request's stream on it - an empty 202 in JSON-response mode, a cleanly-ended event stream in SSE mode - instead of parking the POST until the session ends. For the same reason the client transport now releases an abandoned request's own POST at every protocol era, not just 2026-07-28: with no response coming, a stream left open would sit parked, or - against an event-store server - resume via Last-Event-ID for an answer that will never exist. At 2025 the cancellation frame is still POSTed; only the lingering stream goes away. The built-in client's abandon and timeout paths never waited on that error frame, so they are unaffected; a caller that hand-sends notifications/cancelled while still awaiting a call now waits rather than receiving the code-0 error, which docs/migration.md notes.
1 parent 45f2a88 commit 21fadc7

12 files changed

Lines changed: 438 additions & 240 deletions

File tree

docs/migration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,14 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params
13881388

13891389
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.
13901390

1391+
### Cancelled requests are no longer answered
1392+
1393+
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"}`. The 2026-07-28 spec says a server MUST NOT send any further messages for a cancelled request, and 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).
1394+
1395+
On the 2025-era streamable HTTP wire, the cancelled request's HTTP exchange still ends rather than staying open: JSON-response mode answers the POST with an empty `202 Accepted`, SSE mode ends the event stream without a response event, and the client tears down the abandoned request's own POST.
1396+
1397+
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 no longer resolves with the code-0 error; stop awaiting it yourself, or use a per-request timeout.
1398+
13911399
### `Server.run()` no longer takes a `stateless` flag
13921400

13931401
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.
@@ -1711,7 +1719,7 @@ except MCPError as e:
17111719

17121720
Behavior changes:
17131721

1714-
- **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.
1722+
- **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.
17151723
- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running.
17161724
- **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.
17171725
- **`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.

src/mcp/client/streamable_http.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,12 @@ def __init__(self, url: str) -> None:
102102
# scheduling is arbitrary). Reused on outbound HTTP that carries no
103103
# per-message header (transport-internal GET/DELETE, and dispatcher-written
104104
# response/error POSTs that bypass the session's stamp), and consulted by
105-
# `_consume_modern_cancellation`. Cleared when an `initialize` message is
105+
# `_apply_outbound_cancellation`. Cleared when an `initialize` message is
106106
# dequeued so a probe-stamped value cannot leak onto the handshake.
107107
self._protocol_version_header: str | None = None
108108
# Every request's POST runs inside one of these so an outbound
109-
# `notifications/cancelled` at 2026 can abort it; see
110-
# `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1).
109+
# `notifications/cancelled` can abort it; see
110+
# `_apply_outbound_cancellation`. Keys are verbatim-typed ("1" is not 1).
111111
self._in_flight_posts: dict[RequestId, _InFlightPost] = {}
112112

113113
def _prepare_headers(self) -> dict[str, str]:
@@ -268,32 +268,28 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
268268
await event_source.response.aclose()
269269
break
270270

271-
def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool:
272-
"""Translate an outbound `notifications/cancelled` at 2026; True means "do not POST".
273-
274-
The 2026 wire defines no client-to-server notifications over streamable
275-
HTTP: closing a request's response stream IS its cancellation signal.
276-
The dispatcher still emits the courtesy frame as its abandon signal
277-
(every outbound cancel names one of our own request ids - the spec
278-
forbids cancelling a request the sender did not issue), so this
279-
transport translates it: when the named request's POST is in flight,
280-
that POST's own recorded era decides - abort-and-swallow at 2026, POST
281-
the frame below it (where the frame is the signal and a disconnect
282-
explicitly is not). With no POST to consult, the cached negotiated
283-
version decides; at 2026 the frame is swallowed even unmatched, so a
284-
late cancel racing the response cannot leak onto the wire.
271+
def _apply_outbound_cancellation(self, session_message: SessionMessage) -> bool:
272+
"""Apply an outbound `notifications/cancelled` to this transport; True means "do not POST".
273+
274+
The dispatcher emits the frame as its abandon signal (it only ever names
275+
one of our own request ids), so the named request's in-flight POST is
276+
aborted at every era: its caller is gone and the stream must not linger
277+
or resume. The POST's recorded era only decides whether the frame is also
278+
POSTed - at 2026 the abort IS the cancellation signal and there are no
279+
client-to-server notifications (swallow); at 2025 the frame is the signal,
280+
so it is POSTed too. With no POST to consult, the cached negotiated version
281+
decides; at 2026 the frame is swallowed even unmatched, so a late cancel
282+
racing the response cannot leak onto the wire.
285283
"""
286284
message = session_message.message
287285
if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"):
288286
return False
289287
request_id = cancelled_request_id_from_params(message.params)
290288
post = self._in_flight_posts.get(request_id) if request_id is not None else None
291289
if post is not None:
292-
if not post.modern:
293-
return False
294290
logger.debug("aborting in-flight POST for cancelled request %r", request_id)
295291
post.scope.cancel()
296-
return True
292+
return post.modern
297293
return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS
298294

299295
async def _run_request_post(
@@ -302,7 +298,7 @@ async def _run_request_post(
302298
post: _InFlightPost,
303299
request_id: RequestId,
304300
) -> None:
305-
"""Run one request's POST inside its abort scope (see `_consume_modern_cancellation`)."""
301+
"""Run one request's POST inside its abort scope (see `_apply_outbound_cancellation`)."""
306302
try:
307303
with post.scope:
308304
await post_fn()
@@ -548,7 +544,7 @@ async def post_writer(
548544

549545
async def _handle_message(session_message: SessionMessage) -> None:
550546
message = session_message.message
551-
if self._consume_modern_cancellation(session_message):
547+
if self._apply_outbound_cancellation(session_message):
552548
return
553549
metadata = (
554550
session_message.metadata

src/mcp/server/streamable_http.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ def _create_session_message(
262262
The close_sse_stream callbacks are only provided when the client supports
263263
resumability (protocol version >= 2025-11-25). Old clients can't resume if
264264
the stream is closed early because they didn't receive a priming event.
265+
Every request carries `on_request_unanswered`, which ends its stream
266+
when the request settles without a response.
265267
"""
268+
end_stream = partial(self._end_request_stream, request_id)
266269
# Only provide close callbacks when client supports resumability
267270
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):
268271

@@ -276,9 +279,10 @@ async def close_standalone_stream_callback() -> None:
276279
request_context=request,
277280
close_sse_stream=close_stream_callback,
278281
close_standalone_sse_stream=close_standalone_stream_callback,
282+
on_request_unanswered=end_stream,
279283
)
280284
else:
281-
metadata = ServerMessageMetadata(request_context=request)
285+
metadata = ServerMessageMetadata(request_context=request, on_request_unanswered=end_stream)
282286

283287
return SessionMessage(message, metadata=metadata)
284288

@@ -390,6 +394,16 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent:
390394

391395
return event_data
392396

397+
async def _end_request_stream(self, request_id: RequestId) -> None:
398+
"""End a request's stream when it settled without a response (e.g. cancelled).
399+
400+
Only the send side, so the reader finishes as a normal end-of-stream: JSON
401+
mode answers the POST with 202, SSE mode ends the event stream. Already
402+
gone if `close_sse_stream()` polling or session teardown released it first.
403+
"""
404+
if streams := self._request_streams.get(request_id): # pragma: no branch
405+
await streams[0].aclose()
406+
393407
async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
394408
"""Clean up memory streams for a given request ID."""
395409
if request_id in self._request_streams: # pragma: no branch
@@ -555,7 +569,10 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
555569
)
556570
request_stream_reader = self._request_streams[request_id][1]
557571
# Process the message
558-
metadata = ServerMessageMetadata(request_context=request)
572+
metadata = ServerMessageMetadata(
573+
request_context=request,
574+
on_request_unanswered=partial(self._end_request_stream, request_id),
575+
)
559576
session_message = SessionMessage(message, metadata=metadata)
560577
await writer.send(session_message)
561578
try:
@@ -573,19 +590,15 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
573590
else: # pragma: no cover
574591
logger.debug(f"received: {event_message.message.method}")
575592

576-
# At this point we should have a response
577593
if response_message:
578594
# Create JSON response
579595
response = self._create_json_response(response_message)
580-
await response(scope, receive, send)
581-
else: # pragma: no cover
582-
# This shouldn't happen in normal operation
583-
logger.error("No response message received before stream closed")
584-
response = self._create_error_response(
585-
"Error processing request: No response received",
586-
HTTPStatus.INTERNAL_SERVER_ERROR,
587-
)
588-
await response(scope, receive, send)
596+
else:
597+
# The stream ended without a response: the request settled
598+
# unanswered (e.g. it was cancelled). End the POST with
599+
# an empty 202 Accepted rather than leaving it open.
600+
response = self._create_json_response(None, HTTPStatus.ACCEPTED)
601+
await response(scope, receive, send)
589602
except Exception: # pragma: no cover
590603
logger.exception("Error processing JSON response")
591604
response = self._create_error_response(

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 43 additions & 26 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,7 +698,10 @@ 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
702707
try:
@@ -711,27 +716,25 @@ async def _handle_request(
711716
key = coerce_request_id(req.id)
712717
if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx:
713718
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"))
719+
if not dctx.cancel_requested.is_set():
720+
# A write interrupted by cancellation may still have delivered
721+
# (a memory-stream send can hand its item to the receiver and
722+
# still raise), so a started answer write counts as sent below:
723+
# peers drop late responses, while a second answer for one id
724+
# would break JSON-RPC.
725+
answer_write_started = True
726+
await self._write_result(req.id, result)
727+
# Reached unanswered: the handler saw the cancel (any mode), or the
728+
# scope absorbed the interrupt at __exit__.
729+
if not answer_write_started:
730+
await self._settle_unanswered(dctx)
729731
except anyio.get_cancelled_exc_class():
730732
# Shutdown: answer the request so the peer isn't left waiting - unless
731733
# 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:
734+
# prefer possibly-zero answers over possibly-two), or the peer already
735+
# cancelled it and stopped waiting. The shielded helper is needed
736+
# because bare awaits re-raise here.
737+
if not answer_write_started and not dctx.cancel_requested.is_set():
735738
await self._final_write(
736739
partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")),
737740
shield=True,
@@ -741,15 +744,19 @@ async def _handle_request(
741744
raise
742745
except Exception as e:
743746
error = handler_exception_to_error_data(e)
744-
if error is not None:
745-
await self._write_error(req.id, error)
746-
else:
747+
unmapped = error is None
748+
if unmapped:
747749
logger.exception("handler for %r raised", req.method)
748750
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
749751
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
750-
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
751-
if self._raise_handler_exceptions:
752-
raise
752+
error = ErrorData(code=0, message=str(e))
753+
# A cancel silences only the wire; the failure stays as visible as before.
754+
if dctx.cancel_requested.is_set():
755+
await self._settle_unanswered(dctx)
756+
else:
757+
await self._write_error(req.id, error)
758+
if unmapped and self._raise_handler_exceptions:
759+
raise
753760
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
754761

755762
def _allocate_id(self) -> int:
@@ -771,6 +778,16 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
771778
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
772779
logger.debug("dropped error for %r: write stream closed", request_id)
773780

781+
async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None:
782+
"""Run the transport's `on_request_unanswered` hook: this request settled with no response.
783+
784+
A shared-channel wire (stdio) has none; legacy streamable HTTP uses it
785+
to complete the request's still-open POST.
786+
"""
787+
metadata = dctx.message_metadata
788+
if isinstance(metadata, ServerMessageMetadata) and metadata.on_request_unanswered is not None:
789+
await metadata.on_request_unanswered()
790+
774791
async def _final_write(
775792
self,
776793
write: Callable[[], Awaitable[None]],

0 commit comments

Comments
 (0)