Skip to content

Commit 706d0ce

Browse files
committed
Terminate cancelled requests in-band on the legacy HTTP wire
Review of the first revision showed that dropping the cancellation frame outright fought the 2025-era streamable HTTP wire, whose per-request SSE stream is the ordered, resumable channel for everything about that request and ends only when a response for its id passes through it. The out-of-band substitutes each broke something: the transport hook closed the stream from the side and could overtake messages already queued through the router (notably the courtesy cancel for a handler's nested elicitation), had no durable form so a resuming client's replay tailed forever, and needed two dispatcher call sites; the client aborting its own POST severed the delivery path for that owed related traffic and turned routine cancellation into a write against a closed socket. The 2026-07-28 MUST NOT that motivates this change never coexists with resumable per-request streams (SEP-2575 removed them), so the fix is scoped by era instead. The dispatcher stays strictly silent for a cancelled request everywhere. The one 2025-era transport terminates the settled request through the same ordered channel with a valid -32800 REQUEST_CANCELLED error (LSP's RequestCancelled) in place of the old code 0 - so ordering, resumption, and the client's channel are all preserved by construction, and old and new clients complete the same way they did before. The client transport is unchanged from main; the dispatcher settles a cancelled request at a single site, containing a raising transport hook like its other callback boundaries.
1 parent 8cd2d22 commit 706d0ce

13 files changed

Lines changed: 214 additions & 152 deletions

File tree

docs/migration.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,11 +1586,11 @@ Previously it also re-raised exceptions yielded by the transport onto the read s
15861586

15871587
### Cancelled requests are no longer answered
15881588

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"}`. 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).
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).
15901590

1591-
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.
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.
15921592

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 no longer resolves with the code-0 error; stop awaiting it yourself, or use a per-request timeout.
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.
15941594

15951595
### `Server.run()` no longer takes a `stateless` flag
15961596

examples/stories/streaming/README.md

Lines changed: 6 additions & 4 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

src/mcp/client/streamable_http.py

Lines changed: 22 additions & 18 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-
# `_apply_outbound_cancellation`. Cleared when an `initialize` message is
105+
# `_consume_modern_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` can abort it; see
110-
# `_apply_outbound_cancellation`. Keys are verbatim-typed ("1" is not 1).
109+
# `notifications/cancelled` at 2026 can abort it; see
110+
# `_consume_modern_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,28 +268,32 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
268268
await event_source.response.aclose()
269269
break
270270

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.
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.
283285
"""
284286
message = session_message.message
285287
if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"):
286288
return False
287289
request_id = cancelled_request_id_from_params(message.params)
288290
post = self._in_flight_posts.get(request_id) if request_id is not None else None
289291
if post is not None:
292+
if not post.modern:
293+
return False
290294
logger.debug("aborting in-flight POST for cancelled request %r", request_id)
291295
post.scope.cancel()
292-
return post.modern
296+
return True
293297
return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS
294298

295299
async def _run_request_post(
@@ -298,7 +302,7 @@ async def _run_request_post(
298302
post: _InFlightPost,
299303
request_id: RequestId,
300304
) -> None:
301-
"""Run one request's POST inside its abort scope (see `_apply_outbound_cancellation`)."""
305+
"""Run one request's POST inside its abort scope (see `_consume_modern_cancellation`)."""
302306
try:
303307
with post.scope:
304308
await post_fn()
@@ -544,7 +548,7 @@ async def post_writer(
544548

545549
async def _handle_message(session_message: SessionMessage) -> None:
546550
message = session_message.message
547-
if self._apply_outbound_cancellation(session_message):
551+
if self._consume_modern_cancellation(session_message):
548552
return
549553
metadata = (
550554
session_message.metadata

src/mcp/server/streamable_http.py

Lines changed: 33 additions & 18 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,10 +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.
265-
Every request carries `on_request_unanswered`, which ends its stream
266-
when the request settles without a response.
272+
Every request carries `on_request_unanswered`, so a request that settles
273+
without a response is still terminated on this era's wire.
267274
"""
268-
end_stream = partial(self._end_request_stream, request_id)
275+
end_stream = partial(self._terminate_unanswered_request, message.id)
269276
# Only provide close callbacks when client supports resumability
270277
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):
271278

@@ -394,15 +401,19 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent:
394401

395402
return event_data
396403

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).
404+
async def _terminate_unanswered_request(self, request_id: RequestId) -> None:
405+
"""Terminate a request that settled without a response (e.g. cancelled).
399406
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.
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.
403413
"""
404-
if streams := self._request_streams.get(request_id): # pragma: no branch
405-
await streams[0].aclose()
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)))
406417

407418
async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
408419
"""Clean up memory streams for a given request ID."""
@@ -571,7 +582,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
571582
# Process the message
572583
metadata = ServerMessageMetadata(
573584
request_context=request,
574-
on_request_unanswered=partial(self._end_request_stream, request_id),
585+
on_request_unanswered=partial(self._terminate_unanswered_request, message.id),
575586
)
576587
session_message = SessionMessage(message, metadata=metadata)
577588
await writer.send(session_message)
@@ -590,15 +601,19 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
590601
else: # pragma: no cover
591602
logger.debug(f"received: {event_message.message.method}")
592603

604+
# At this point we should have a response
593605
if response_message:
594606
# Create JSON response
595607
response = self._create_json_response(response_message)
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)
608+
await response(scope, receive, send)
609+
else: # pragma: no cover
610+
# This shouldn't happen in normal operation
611+
logger.error("No response message received before stream closed")
612+
response = self._create_error_response(
613+
"Error processing request: No response received",
614+
HTTPStatus.INTERNAL_SERVER_ERROR,
615+
)
616+
await response(scope, receive, send)
602617
except Exception: # pragma: no cover
603618
logger.exception("Error processing JSON response")
604619
response = self._create_error_response(

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ async def _handle_request(
704704
instead, and `_settle_unanswered` tells the transport.
705705
"""
706706
answer_write_started = False
707+
handler_failure: BaseException | None = None # re-raised once the request settles
707708
try:
708709
with scope:
709710
try:
@@ -724,10 +725,6 @@ async def _handle_request(
724725
# would break JSON-RPC.
725726
answer_write_started = True
726727
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)
731728
except anyio.get_cancelled_exc_class():
732729
# Shutdown: answer the request so the peer isn't left waiting - unless
733730
# an answer write already started (it may have reached the transport;
@@ -744,19 +741,24 @@ async def _handle_request(
744741
raise
745742
except Exception as e:
746743
error = handler_exception_to_error_data(e)
747-
unmapped = error is None
748-
if unmapped:
744+
if error is None:
749745
logger.exception("handler for %r raised", req.method)
750746
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
751747
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
752748
error = ErrorData(code=0, message=str(e))
749+
if self._raise_handler_exceptions:
750+
handler_failure = e
753751
# 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:
752+
if not dctx.cancel_requested.is_set():
753+
answer_write_started = True
757754
await self._write_error(req.id, error)
758-
if unmapped and self._raise_handler_exceptions:
759-
raise
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
760762
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
761763

762764
def _allocate_id(self) -> int:
@@ -781,12 +783,19 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
781783
async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None:
782784
"""Run the transport's `on_request_unanswered` hook: this request settled with no response.
783785
784-
A shared-channel wire (stdio) has none; legacy streamable HTTP uses it
785-
to complete the request's still-open POST.
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.
786789
"""
787790
metadata = dctx.message_metadata
788-
if isinstance(metadata, ServerMessageMetadata) and metadata.on_request_unanswered is not None:
791+
if not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None:
792+
return
793+
try:
789794
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")
790799

791800
async def _final_write(
792801
self,

src/mcp/shared/message.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ class ServerMessageMetadata:
4141
close_sse_stream: CloseSSEStreamCallback | None = None
4242
# Callback to close the standalone GET SSE stream (for unsolicited notifications)
4343
close_standalone_sse_stream: CloseSSEStreamCallback | None = None
44-
# Callback for a request that settles without a response (e.g. cancelled),
45-
# so the transport can end the per-request state a response would have closed.
44+
# Callback the dispatcher runs when this request settles without a response
45+
# (e.g. it was cancelled), for a transport whose wire must still end the
46+
# request even though no response is written.
4647
on_request_unanswered: Callable[[], Awaitable[None]] | None = None
4748

4849

0 commit comments

Comments
 (0)