Skip to content

Commit 8cd2d22

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 e8ef138 commit 8cd2d22

12 files changed

Lines changed: 439 additions & 241 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"}`. 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).
1590+
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.
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 no longer resolves with the code-0 error; 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.

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(

0 commit comments

Comments
 (0)