From 8cd2d22e4e40742ed6a95998c26f462036a8631d Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:49:58 +0000 Subject: [PATCH 1/3] 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. --- docs/migration.md | 12 +- src/mcp/client/streamable_http.py | 40 ++-- src/mcp/server/streamable_http.py | 37 ++- src/mcp/shared/jsonrpc_dispatcher.py | 69 ++++-- src/mcp/shared/message.py | 3 + tests/client/test_streamable_http.py | 18 +- tests/interaction/_requirements.py | 34 ++- .../interaction/lowlevel/test_cancellation.py | 59 +++-- .../transports/test_client_transport_http.py | 43 ++++ .../transports/test_streamable_http.py | 88 ++++++- tests/server/test_cancel_handling.py | 53 +++-- tests/shared/test_jsonrpc_dispatcher.py | 224 +++++++++--------- 12 files changed, 439 insertions(+), 241 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 501fd8aba1..42c93cbdc7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1584,6 +1584,14 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params 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. +### Cancelled requests are no longer answered + +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). + +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. + +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. + ### `Server.run()` no longer takes a `stateless` flag 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: Behavior changes: -- **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. +- **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. - **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. -- **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. +- **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. - **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. - **`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. - **`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. diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index c95cfcf50b..9c5bfdbc0d 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -102,12 +102,12 @@ def __init__(self, url: str) -> None: # scheduling is arbitrary). Reused on outbound HTTP that carries no # per-message header (transport-internal GET/DELETE, and dispatcher-written # response/error POSTs that bypass the session's stamp), and consulted by - # `_consume_modern_cancellation`. Cleared when an `initialize` message is + # `_apply_outbound_cancellation`. Cleared when an `initialize` message is # dequeued so a probe-stamped value cannot leak onto the handshake. self._protocol_version_header: str | None = None # Every request's POST runs inside one of these so an outbound - # `notifications/cancelled` at 2026 can abort it; see - # `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1). + # `notifications/cancelled` can abort it; see + # `_apply_outbound_cancellation`. Keys are verbatim-typed ("1" is not 1). self._in_flight_posts: dict[RequestId, _InFlightPost] = {} def _prepare_headers(self) -> dict[str, str]: @@ -268,20 +268,18 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: await event_source.response.aclose() break - def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool: - """Translate an outbound `notifications/cancelled` at 2026; True means "do not POST". - - The 2026 wire defines no client-to-server notifications over streamable - HTTP: closing a request's response stream IS its cancellation signal. - The dispatcher still emits the courtesy frame as its abandon signal - (every outbound cancel names one of our own request ids - the spec - forbids cancelling a request the sender did not issue), so this - transport translates it: when the named request's POST is in flight, - that POST's own recorded era decides - abort-and-swallow at 2026, POST - the frame below it (where the frame is the signal and a disconnect - explicitly is not). With no POST to consult, the cached negotiated - version decides; at 2026 the frame is swallowed even unmatched, so a - late cancel racing the response cannot leak onto the wire. + def _apply_outbound_cancellation(self, session_message: SessionMessage) -> bool: + """Apply an outbound `notifications/cancelled` to this transport; True means "do not POST". + + The dispatcher emits the frame as its abandon signal (it only ever names + one of our own request ids), so the named request's in-flight POST is + aborted at every era: its caller is gone and the stream must not linger + or resume. The POST's recorded era only decides whether the frame is also + POSTed - at 2026 the abort IS the cancellation signal and there are no + client-to-server notifications (swallow); at 2025 the frame is the signal, + so it is POSTed too. With no POST to consult, the cached negotiated version + decides; at 2026 the frame is swallowed even unmatched, so a late cancel + racing the response cannot leak onto the wire. """ message = session_message.message if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"): @@ -289,11 +287,9 @@ def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool: request_id = cancelled_request_id_from_params(message.params) post = self._in_flight_posts.get(request_id) if request_id is not None else None if post is not None: - if not post.modern: - return False logger.debug("aborting in-flight POST for cancelled request %r", request_id) post.scope.cancel() - return True + return post.modern return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS async def _run_request_post( @@ -302,7 +298,7 @@ async def _run_request_post( post: _InFlightPost, request_id: RequestId, ) -> None: - """Run one request's POST inside its abort scope (see `_consume_modern_cancellation`).""" + """Run one request's POST inside its abort scope (see `_apply_outbound_cancellation`).""" try: with post.scope: await post_fn() @@ -548,7 +544,7 @@ async def post_writer( async def _handle_message(session_message: SessionMessage) -> None: message = session_message.message - if self._consume_modern_cancellation(session_message): + if self._apply_outbound_cancellation(session_message): return metadata = ( session_message.metadata diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index d316345c7e..28a9cfb287 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -262,7 +262,10 @@ def _create_session_message( The close_sse_stream callbacks are only provided when the client supports resumability (protocol version >= 2025-11-25). Old clients can't resume if the stream is closed early because they didn't receive a priming event. + Every request carries `on_request_unanswered`, which ends its stream + when the request settles without a response. """ + end_stream = partial(self._end_request_stream, request_id) # Only provide close callbacks when client supports resumability if self._event_store and is_version_at_least(protocol_version, "2025-11-25"): @@ -276,9 +279,10 @@ async def close_standalone_stream_callback() -> None: request_context=request, close_sse_stream=close_stream_callback, close_standalone_sse_stream=close_standalone_stream_callback, + on_request_unanswered=end_stream, ) else: - metadata = ServerMessageMetadata(request_context=request) + metadata = ServerMessageMetadata(request_context=request, on_request_unanswered=end_stream) return SessionMessage(message, metadata=metadata) @@ -390,6 +394,16 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent: return event_data + async def _end_request_stream(self, request_id: RequestId) -> None: + """End a request's stream when it settled without a response (e.g. cancelled). + + Only the send side, so the reader finishes as a normal end-of-stream: JSON + mode answers the POST with 202, SSE mode ends the event stream. Already + gone if `close_sse_stream()` polling or session teardown released it first. + """ + if streams := self._request_streams.get(request_id): # pragma: no branch + await streams[0].aclose() + async def _clean_up_memory_streams(self, request_id: RequestId) -> None: """Clean up memory streams for a given request ID.""" 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 ) request_stream_reader = self._request_streams[request_id][1] # Process the message - metadata = ServerMessageMetadata(request_context=request) + metadata = ServerMessageMetadata( + request_context=request, + on_request_unanswered=partial(self._end_request_stream, request_id), + ) session_message = SessionMessage(message, metadata=metadata) await writer.send(session_message) try: @@ -573,19 +590,15 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re else: # pragma: no cover logger.debug(f"received: {event_message.message.method}") - # At this point we should have a response if response_message: # Create JSON response response = self._create_json_response(response_message) - await response(scope, receive, send) - else: # pragma: no cover - # This shouldn't happen in normal operation - logger.error("No response message received before stream closed") - response = self._create_error_response( - "Error processing request: No response received", - HTTPStatus.INTERNAL_SERVER_ERROR, - ) - await response(scope, receive, send) + else: + # The stream ended without a response: the request settled + # unanswered (e.g. it was cancelled). End the POST with + # an empty 202 Accepted rather than leaving it open. + response = self._create_json_response(None, HTTPStatus.ACCEPTED) + await response(scope, receive, send) except Exception: # pragma: no cover logger.exception("Error processing JSON response") response = self._create_error_response( diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 42798fdc54..f752aeaeff 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -80,7 +80,9 @@ PeerCancelMode = Literal["interrupt", "signal"] """How `notifications/cancelled` is applied: `"interrupt"` (default) cancels -the handler's scope; `"signal"` only sets `ctx.cancel_requested`.""" +the handler's scope; `"signal"` only sets `ctx.cancel_requested` and lets the +handler run to completion. Either way the cancelled request is never +answered - the handler's eventual result or error is dropped, not written.""" def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None: @@ -696,7 +698,10 @@ async def _handle_request( ) -> None: """Run `on_request` for one inbound request and write its response. - The single exception-to-wire boundary: handler exceptions become `JSONRPCError` here. + The single exception-to-wire boundary: handler exceptions become + `JSONRPCError` here. A request the peer cancelled is never answered + (spec: MUST NOT send further messages for it) - it settles unanswered + instead, and `_settle_unanswered` tells the transport. """ answer_write_started = False try: @@ -711,27 +716,25 @@ async def _handle_request( key = coerce_request_id(req.id) if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx: del self._in_flight[key] - # A write interrupted by cancellation may still have delivered - # (a memory-stream send can hand its item to the receiver and - # still raise), so a started answer write counts as sent below: - # peers drop late responses, while a second answer for one id - # would break JSON-RPC. - answer_write_started = True - await self._write_result(req.id, result) - if scope.cancelled_caught: - # anyio absorbs the scope's own cancel at __exit__, and - # `cancelled_caught` (unlike `cancel_called`) guarantees the - # result write above did not happen - no double response. - # TODO(L38): spec says SHOULD NOT respond after cancel; - # the existing server always has, so match that for now. - answer_write_started = True - await self._write_error(req.id, ErrorData(code=0, message="Request cancelled")) + if not dctx.cancel_requested.is_set(): + # A write interrupted by cancellation may still have delivered + # (a memory-stream send can hand its item to the receiver and + # still raise), so a started answer write counts as sent below: + # peers drop late responses, while a second answer for one id + # would break JSON-RPC. + answer_write_started = True + await self._write_result(req.id, result) + # Reached unanswered: the handler saw the cancel (any mode), or the + # scope absorbed the interrupt at __exit__. + if not answer_write_started: + await self._settle_unanswered(dctx) except anyio.get_cancelled_exc_class(): # Shutdown: answer the request so the peer isn't left waiting - unless # an answer write already started (it may have reached the transport; - # prefer possibly-zero answers over possibly-two). The shielded helper - # is needed because bare awaits re-raise here. - if not answer_write_started: + # prefer possibly-zero answers over possibly-two), or the peer already + # cancelled it and stopped waiting. The shielded helper is needed + # because bare awaits re-raise here. + if not answer_write_started and not dctx.cancel_requested.is_set(): await self._final_write( partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")), shield=True, @@ -741,15 +744,19 @@ async def _handle_request( raise except Exception as e: error = handler_exception_to_error_data(e) - if error is not None: - await self._write_error(req.id, error) - else: + unmapped = error is None + if unmapped: logger.exception("handler for %r raised", req.method) # TODO(L58): code=0 pins existing-server compat; JSON-RPC says # INTERNAL_ERROR. Revisit per the suite's divergence entry. - await self._write_error(req.id, ErrorData(code=0, message=str(e))) - if self._raise_handler_exceptions: - raise + error = ErrorData(code=0, message=str(e)) + # A cancel silences only the wire; the failure stays as visible as before. + if dctx.cancel_requested.is_set(): + await self._settle_unanswered(dctx) + else: + await self._write_error(req.id, error) + if unmapped and self._raise_handler_exceptions: + raise # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id. def _allocate_id(self) -> int: @@ -771,6 +778,16 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None: except (anyio.BrokenResourceError, anyio.ClosedResourceError): logger.debug("dropped error for %r: write stream closed", request_id) + async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None: + """Run the transport's `on_request_unanswered` hook: this request settled with no response. + + A shared-channel wire (stdio) has none; legacy streamable HTTP uses it + to complete the request's still-open POST. + """ + metadata = dctx.message_metadata + if isinstance(metadata, ServerMessageMetadata) and metadata.on_request_unanswered is not None: + await metadata.on_request_unanswered() + async def _final_write( self, write: Callable[[], Awaitable[None]], diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py index 236569fac2..bad0865ded 100644 --- a/src/mcp/shared/message.py +++ b/src/mcp/shared/message.py @@ -41,6 +41,9 @@ class ServerMessageMetadata: close_sse_stream: CloseSSEStreamCallback | None = None # Callback to close the standalone GET SSE stream (for unsolicited notifications) close_standalone_sse_stream: CloseSSEStreamCallback | None = None + # Callback for a request that settles without a response (e.g. cancelled), + # so the transport can end the per-request state a response would have closed. + on_request_unanswered: Callable[[], Awaitable[None]] | None = None MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index d21f520daf..dec2e5bb6b 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -261,10 +261,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.anyio @pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"]) -async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None: +async def test_legacy_cancelled_frame_posts_and_releases_the_abandoned_stream(stamped_version: str | None) -> None: """Below 2026 — or before any stamped POST has revealed the version — the frame is - the spec's cancellation signal: it POSTs, and the request's stream stays open - (a 2025 disconnect is explicitly not a cancel).""" + the spec's cancellation signal, so it POSTs; the abandoned request's own response + stream is also released rather than left parked (or resuming) for an answer the + caller no longer wants. The frame, not the disconnect, carries the cancel: a 2025 + server treats the disconnect as nothing.""" parked = _ParkedSSEStream() posted: list[dict[str, Any]] = [] frame_posted = anyio.Event() @@ -293,8 +295,7 @@ async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_v ) ) await frame_posted.wait() - # Checked before teardown: exiting the transport cancels the parked POST. - assert not parked.closed.is_set() + await parked.closed.wait() # the abandoned request's stream is torn down, not left open assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"] @@ -389,8 +390,8 @@ async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None: """The translation follows the era the NAMED request was sent under, not the cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation - semantics (frame on the wire, stream left open) even after a later message - flips the negotiated version to 2026.""" + semantics (frame on the wire) even after a later message flips the negotiated + version to 2026 - the abandoned stream is released either way.""" parked = _ParkedSSEStream() posted: list[dict[str, Any]] = [] frame_posted = anyio.Event() @@ -433,8 +434,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) ) await frame_posted.wait() - # Checked before teardown: exiting the transport cancels the parked POST. - assert not parked.closed.is_set() + await parked.closed.wait() # the abandoned request's stream is released too assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"] diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index fbc7c13846..01c4b0d5bb 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -537,15 +537,11 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", behavior=( "A cancellation notification for an in-flight request stops the server-side handler, and the " - "receiver does not send a response for the cancelled request." + "receiver does not send a response for the cancelled request - no result and no error." ), - divergence=Divergence( - note=( - "The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled " - "request; both seats send an error response (code 0, 'Request cancelled') instead — the " - "server for cancelled client requests, and the client for cancelled server-initiated " - "requests — which is what unblocks the sender's pending call." - ), + note=( + "Over streamable HTTP the cancelled request's own POST still completes; see " + "transport:streamable-http:cancelled-request-completes." ), arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), @@ -2559,6 +2555,19 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.", ), + "transport:streamable-http:cancelled-request-completes": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#behavior-requirements", + behavior=( + "A request cancelled through notifications/cancelled leaves no response to send, but its POST " + "still completes: an empty 202 in JSON-response mode, an event stream that ends with no " + "response event in SSE mode - the per-request stream is not left open." + ), + transports=("streamable-http",), + note=( + "Only streamable HTTP holds a per-request stream that a response would otherwise close; the " + "unanswered-request signal is what releases it." + ), + ), "transport:streamable-http:stateless": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", behavior=( @@ -3381,12 +3390,17 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow", behavior=( "At 2025-era revisions, abandoning an in-flight request POSTs exactly one " - "notifications/cancelled naming its request id." + "notifications/cancelled naming its request id, and releases the abandoned request's own " + "response stream so it neither stays parked nor resumes." ), transports=("streamable-http",), removed_in="2026-07-28", superseded_by="client-transport:http:cancel-closes-stream", - note="HTTP-only by nature: pins that the frame travels as its own POST on the legacy HTTP wire.", + note=( + "HTTP-only by nature: pins that the frame travels as its own POST on the legacy HTTP wire. " + "The frame, not the released stream, is the signal - a 2025 server treats the disconnect " + "as nothing." + ), ), "client-transport:http:concurrent-streams": Requirement( source="sdk", diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index ecbf1a088d..923ef8cf41 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -15,7 +15,6 @@ REQUEST_TIMEOUT, CallToolResult, EmptyResult, - ErrorData, Implementation, InitializeResult, JSONRPCNotification, @@ -28,7 +27,7 @@ Tool, ) -from mcp import MCPError +from mcp import Client, MCPError from mcp.client import ClientRequestContext, ClientSession, IncomingMessage from mcp.server import Server, ServerRequestContext from mcp.shared.memory import MessageStream, create_client_server_memory_streams @@ -40,19 +39,30 @@ pytestmark = pytest.mark.anyio +async def _record_result(client: Client, outcomes: list[object]) -> None: + """Await the doomed `block` call and record a result, should one ever arrive. + + Nothing ever arrives for it, so this parks until the task is abandoned; an error response + would surface here as an uncaught MCPError and fail the test. + """ + outcomes.append(await client.call_tool("block", {})) + raise NotImplementedError # unreachable: the task is abandoned first + + @requirement("protocol:cancel:in-flight") @requirement("protocol:cancel:handler-abort-propagates") async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None: - """Cancelling an in-flight request interrupts its handler and fails the pending call. + """Cancelling an in-flight request interrupts its handler, and the server sends no response for it. - The server answers the cancelled request with an error response (the spec says it should - not respond at all; see the divergence note on the requirement), so the caller's pending - request raises rather than hanging. + The cancellation is scripted by hand while a sibling task still awaits the call, which is + something a well-behaved sender never does (per spec it stops waiting once it cancels). That + lets the test prove the negative: after the handler is interrupted and the connection has + quiesced, no server response has reached the still-parked call. """ started = anyio.Event() handler_cancelled = anyio.Event() request_ids: list[types.RequestId] = [] - errors: list[ErrorData] = [] + outcomes: list[object] = [] async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "block" @@ -70,30 +80,26 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: with anyio.fail_after(5): - async with anyio.create_task_group() as task_group: - - async def call_and_capture_error() -> None: - with pytest.raises(MCPError) as exc_info: - await client.call_tool("block", {}) - errors.append(exc_info.value.error) - - task_group.start_soon(call_and_capture_error) + async with anyio.create_task_group() as task_group: # pragma: no branch + task_group.start_soon(_record_result, client, outcomes) await started.wait() await client.session.send_notification( types.CancelledNotification( params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted") ) ) - - await handler_cancelled.wait() - - assert errors == snapshot([ErrorData(code=0, message="Request cancelled")]) + await handler_cancelled.wait() + # Let anything the server was going to send be delivered before checking. + await anyio.wait_all_tasks_blocked() + assert outcomes == [] + task_group.cancel_scope.cancel() # abandon the call if it is still parked @requirement("protocol:cancel:server-survives") async def test_session_serves_requests_after_cancellation(connect: Connect) -> None: """A request cancelled mid-flight does not poison the session: the next request succeeds.""" started = anyio.Event() + handler_cancelled = anyio.Event() request_ids: list[types.RequestId] = [] async def list_tools( @@ -112,7 +118,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara assert ctx.request_id is not None request_ids.append(ctx.request_id) started.set() - await anyio.Event().wait() # blocks until cancelled + try: + await anyio.Event().wait() # blocks until cancelled + except anyio.get_cancelled_exc_class(): + handler_cancelled.set() + raise raise NotImplementedError # unreachable server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool) @@ -120,16 +130,13 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: with anyio.fail_after(5): async with anyio.create_task_group() as task_group: - - async def call_and_swallow_cancellation_error() -> None: - with pytest.raises(MCPError): - await client.call_tool("block", {}) - - task_group.start_soon(call_and_swallow_cancellation_error) + task_group.start_soon(_record_result, client, list[object]()) await started.wait() await client.session.send_notification( types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0])) ) + await handler_cancelled.wait() + task_group.cancel_scope.cancel() # abandon the parked call result = await client.call_tool("echo", {}) diff --git a/tests/interaction/transports/test_client_transport_http.py b/tests/interaction/transports/test_client_transport_http.py index 625fcaad8d..6c7b1eab49 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -350,3 +350,46 @@ async def call_and_abandon() -> None: cancels = [p for p in posts if p.get("method") == "notifications/cancelled"] assert len(block_calls) == 1 assert [c["params"]["requestId"] for c in cancels] == [block_calls[0]["id"]] + + +@requirement("client-transport:http:cancel-posts-frame") +async def test_at_2025_abandoning_a_resumable_call_does_not_reconnect() -> None: + """Abandoning a call against a resumable (event-store) server tears the call's stream down. + + The server ends a cancelled request's stream without a response; a client that kept the + abandoned stream open would take the priming event's id back to a Last-Event-ID reconnect for + an answer that will never come. Releasing the stream on abandonment means no such GET + follows: the only requests after the tool call are the cancel frame and the closing DELETE. + """ + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + requests: list[tuple[str, str | None]] = [] + + async def record(request: httpx2.Request) -> None: + requests.append((request.method, request.headers.get("last-event-id"))) + + server = _blocking_server(handler_started, handler_cancelled) + async with mounted_app(server, event_store=SequencedEventStore(), retry_interval=0, on_request=record) as ( + http, + _, + ): + async with client_via_http(http) as client: + abandon = anyio.CancelScope() + + async def call_and_abandon() -> None: + with abandon: + await client.call_tool("block", {}) + raise NotImplementedError # unreachable: the call never resolves + + async with anyio.create_task_group() as tg: + tg.start_soon(call_and_abandon) + with anyio.fail_after(5): + await handler_started.wait() + abandon.cancel() + with anyio.fail_after(5): + await handler_cancelled.wait() + # Quiesce so any reconnect the transport was going to make would have been issued. + await anyio.wait_all_tasks_blocked() + + assert [method for method, _ in requests].count("DELETE") == 1 + assert all(last_event_id is None for _, last_event_id in requests) diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py index cb22e7ab87..ce2857d50d 100644 --- a/tests/interaction/transports/test_streamable_http.py +++ b/tests/interaction/transports/test_streamable_http.py @@ -12,9 +12,12 @@ from inline_snapshot import snapshot from mcp_types import ( INVALID_REQUEST, + CallToolRequestParams, CallToolResult, ElicitRequestParams, ElicitResult, + JSONRPCMessage, + JSONRPCRequest, LoggingMessageNotification, LoggingMessageNotificationParams, ResourceUpdatedNotification, @@ -24,10 +27,18 @@ 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.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 +179,78 @@ async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> 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-completes") +@pytest.mark.parametrize("json_response", [True, False], ids=["json-response", "sse-response"]) +async def test_cancelled_request_still_completes_its_post(json_response: bool) -> None: + """A cancelled request has no response to send, yet its POST is not left open. + + The dispatcher signals that the request settled unanswered and the transport ends the + per-request stream: an empty 202 in JSON-response mode, a cleanly-ended event stream carrying + no response event in SSE mode. 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_status: list[int] = [] + call_messages: 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_status.append(response.status_code) + assert response.content == b"" + else: + response, messages = await post_jsonrpc(http, call_body, session_id=session_id) + call_status.append(response.status_code) + call_messages.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_status == [202 if json_response else 200] + assert call_messages == [] diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py index 3d32adb3c8..fd0e4b28af 100644 --- a/tests/server/test_cancel_handling.py +++ b/tests/server/test_cancel_handling.py @@ -22,7 +22,6 @@ from mcp import Client from mcp.server import Server, ServerRequestContext -from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage @@ -33,6 +32,7 @@ async def test_server_remains_functional_after_cancel(): # Track tool calls call_count = 0 ev_first_call = anyio.Event() + ev_first_call_cancelled = anyio.Event() first_request_id = None async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: @@ -53,38 +53,45 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar if call_count == 1: first_request_id = ctx.request_id ev_first_call.set() - await anyio.sleep(5) # First call is slow + try: + await anyio.sleep_forever() # First call blocks until cancelled + except anyio.get_cancelled_exc_class(): + ev_first_call_cancelled.set() + raise return CallToolResult(content=[TextContent(type="text", text=f"Call number: {call_count}")]) raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) async with Client(server, mode="legacy") as client: - # First request (will be cancelled) + # First request (will be cancelled server-side, then abandoned here: a + # cancelled request is never answered, so nothing would wake this call) async def first_request(): - try: - await client.session.send_request( - CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})), - CallToolResult, - ) - pytest.fail("First request should have been cancelled") # pragma: no cover - except MCPError: - pass # Expected + await client.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})), + CallToolResult, + ) + raise NotImplementedError # unreachable: the task is cancelled before any answer # Start first request - async with anyio.create_task_group() as tg: - tg.start_soon(first_request) - - # Wait for it to start - await ev_first_call.wait() - - # Cancel it - assert first_request_id is not None - await client.session.send_notification( - CancelledNotification( - params=CancelledNotificationParams(request_id=first_request_id, reason="Testing server recovery"), + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + tg.start_soon(first_request) + + # Wait for it to start + await ev_first_call.wait() + + # Cancel it + assert first_request_id is not None + await client.session.send_notification( + CancelledNotification( + params=CancelledNotificationParams( + request_id=first_request_id, reason="Testing server recovery" + ), + ) ) - ) + await ev_first_call_cancelled.wait() + tg.cancel_scope.cancel() # abandon the parked call # Second request (should work normally) result = await client.call_tool("test_tool", {}) diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 5c29c7e3ce..737d289d78 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -22,6 +22,7 @@ CancelledNotificationParams, ErrorData, JSONRPCError, + JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, @@ -34,10 +35,11 @@ from mcp.server import Server, ServerRequestContext from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream -from mcp.shared.dispatcher import CallOptions, DispatchContext, coerce_request_id +from mcp.shared.dispatcher import CallOptions, DispatchContext, OnRequest, coerce_request_id from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage] JSONRPCDispatcher, + PeerCancelMode, _OutboundPlan, _Pending, _plan_outbound, @@ -122,113 +124,120 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | assert exc.value.__cause__ is None # cause does not survive the wire -@pytest.mark.anyio -async def test_peer_cancel_interrupt_mode_writes_cancelled_error_response(): - """Matches the existing server: a peer-cancelled request is answered with code=0.""" - handler_started = anyio.Event() - handler_exited = anyio.Event() - seen_ctx: list[DCtx] = [] - - async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: - seen_ctx.append(ctx) - handler_started.set() - try: - await anyio.sleep_forever() - finally: - handler_exited.set() - raise NotImplementedError +async def _drive_cancelled_request( + on_request: OnRequest, *, peer_cancel_mode: PeerCancelMode = "interrupt", done: anyio.Event +) -> tuple[list[JSONRPCMessage], list[RequestId]]: + """Send request 1, cancel it, then send an uncancelled control request 2. - seen_error: list[ErrorData] = [] - async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_): - with anyio.fail_after(5): - async with anyio.create_task_group() as tg: # pragma: no branch - - async def call_then_record() -> None: - with pytest.raises(MCPError) as exc: - await client.send_raw_request("slow", None) - seen_error.append(exc.value.error) - - tg.start_soon(call_then_record) - await handler_started.wait() - await client.notify("notifications/cancelled", {"requestId": 1}) - await handler_exited.wait() - assert seen_ctx[0].cancel_requested.is_set() - assert seen_error == [ErrorData(code=0, message="Request cancelled")] - - -@pytest.mark.anyio -async def test_peer_cancel_landing_after_handlers_last_checkpoint_writes_only_the_result(): - """A peer cancel that fails to interrupt the handler writes only the result: one answer per - id goes on the wire (SDK-defined). The recording stream is needed because a memory stream's - `send` checkpoints, letting the deferred cancellation land mid-write and hide a double answer.""" + Returns (answers written, ids settled unanswered). The control request proves the write + path is live, so an empty answer for id 1 is the suppression under test. Both requests + carry an `on_request_unanswered` callback; only cancelled request 1 should fire it. + Server-only, since the cancelled request is never answered. `done` is set by the + handler once request 1 reaches the state under test. + """ c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) recording = RecordingWriteStream() - server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + c2s_recv, recording, peer_cancel_mode=peer_cancel_mode + ) handler_started = anyio.Event() + unanswered: list[RequestId] = [] - async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + async def on_request_with_control(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "control": + return {"control": True} handler_started.set() - await ctx.cancel_requested.wait() - return {"completed": "after-cancel"} + return await on_request(ctx, method, params) async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: pass # the cancelled notification is teed here; nothing to observe + def request(request_id: RequestId, method: str) -> SessionMessage: + async def on_unanswered() -> None: + unanswered.append(request_id) + + return SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=None), + metadata=ServerMessageMetadata(on_request_unanswered=on_unanswered), + ) + + cancel = JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}) try: async with anyio.create_task_group() as tg: - await tg.start(server.run, on_request, on_notify) - await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None))) + await tg.start(server.run, on_request_with_control, on_notify) + await c2s_send.send(request(1, "t")) with anyio.fail_after(5): await handler_started.wait() - # The cancel is also the handler's wakeup, so anyio defers it and the handler completes. - await c2s_send.send( - SessionMessage( - message=JSONRPCNotification( - jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1} - ) - ) - ) - # Quiesce: the handler has resumed, completed, and exited its scope. + await c2s_send.send(SessionMessage(message=cancel)) + await done.wait() + await c2s_send.send(request(2, "control")) + # Quiesce: let both handler tasks run to the end of `_handle_request`, + # so every write they were going to make has been recorded. await anyio.wait_all_tasks_blocked() tg.cancel_scope.cancel() finally: c2s_send.close() c2s_recv.close() - assert [m.message for m in recording.sent] == [ - JSONRPCResponse(jsonrpc="2.0", id=1, result={"completed": "after-cancel"}) - ] + return [m.message for m in recording.sent], unanswered + + +_CONTROL_ONLY: list[JSONRPCMessage] = [JSONRPCResponse(jsonrpc="2.0", id=2, result={"control": True})] +"""Everything on the wire after a cancelled request 1: only the uncancelled control request 2 is answered.""" @pytest.mark.anyio -async def test_peer_cancel_signal_mode_sets_event_but_handler_runs_to_completion(): - handler_started = anyio.Event() +async def test_peer_cancel_interrupt_mode_interrupts_handler_and_writes_no_response(): + """Spec MUST NOT: a cancelled request is never answered - not even with an error - and settles unanswered.""" + handler_exited = anyio.Event() + seen_ctx: list[DCtx] = [] + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + seen_ctx.append(ctx) + try: + await anyio.sleep_forever() + finally: + handler_exited.set() + raise NotImplementedError + + assert await _drive_cancelled_request(on_request, done=handler_exited) == (_CONTROL_ONLY, [1]) + assert seen_ctx[0].cancel_requested.is_set() + + +@pytest.mark.anyio +async def test_peer_cancel_signal_mode_sets_event_and_drops_the_completed_handlers_result(): + """`"signal"` mode lets the handler run to completion, but the cancelled request is still not answered.""" cancel_seen = anyio.Event() - async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: - handler_started.set() + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: await ctx.cancel_requested.wait() cancel_seen.set() return {"finished": True} - def factory(*, can_send_request: bool = True): - client, server, close = jsonrpc_pair(can_send_request=can_send_request) - assert isinstance(server, JSONRPCDispatcher) - server._peer_cancel_mode = "signal" # pyright: ignore[reportPrivateUsage] - return client, server, close + assert await _drive_cancelled_request(on_request, peer_cancel_mode="signal", done=cancel_seen) == ( + _CONTROL_ONLY, + [1], + ) - result_box: list[dict[str, Any]] = [] - async with running_pair(factory, server_on_request=server_on_request) as (client, *_): - with anyio.fail_after(5): - async with anyio.create_task_group() as tg: # pragma: no branch - async def call() -> None: - result_box.append(await client.send_raw_request("slow", None)) +@pytest.mark.anyio +@pytest.mark.parametrize( + "handler_failure", + [RuntimeError("cleanup failed"), MCPError(code=INTERNAL_ERROR, message="cleanup failed")], + ids=["unmapped-exception", "mcp-error"], +) +async def test_peer_cancel_drops_the_error_of_a_handler_that_fails_after_cancel(handler_failure: Exception): + """A handler that turns its cancellation into an exception - mapped or not - writes no error response either.""" + handler_failed = anyio.Event() - tg.start_soon(call) - await handler_started.wait() - await client.notify("notifications/cancelled", {"requestId": 1}) - await cancel_seen.wait() - assert result_box == [{"finished": True}] + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + try: + await anyio.sleep_forever() + except anyio.get_cancelled_exc_class(): + handler_failed.set() + raise handler_failure from None + raise NotImplementedError + + assert await _drive_cancelled_request(on_request, done=handler_failed) == (_CONTROL_ONLY, [1]) @pytest.mark.anyio @@ -1592,14 +1601,16 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | async with anyio.create_task_group() as tg: # pragma: no branch async def call() -> None: - with pytest.raises(MCPError): - await client.send_raw_request("slow", None) + # Never answered; abandoned below without a courtesy cancel so `srec` sees only the peer's. + await client.send_raw_request("slow", None, {"cancel_on_abandon": False}) + raise NotImplementedError # unreachable: the task is cancelled first tg.start_soon(call) await handler_started.wait() await client.notify("notifications/cancelled", {"requestId": 1}) await handler_exited.wait() await srec.notified.wait() + tg.cancel_scope.cancel() # abandon the parked call assert srec.notifications == [("notifications/cancelled", {"requestId": 1})] @@ -2057,8 +2068,8 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | async with anyio.create_task_group() as tg: # pragma: no branch async def call() -> None: - with pytest.raises(MCPError): - await client.send_raw_request("slow", None) + await client.send_raw_request("slow", None) # never answered; abandoned below + raise NotImplementedError # unreachable: the task is cancelled first tg.start_soon(call) await handler_started.wait() @@ -2068,6 +2079,7 @@ async def call() -> None: assert not handler_exited.is_set() await client.notify("notifications/cancelled", {"requestId": 1}) await handler_exited.wait() + tg.cancel_scope.cancel() # abandon the parked call @pytest.mark.anyio @@ -2160,11 +2172,15 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> async def test_cancelled_correlates_across_string_and_int_request_id_forms(request_id: RequestId, cancel_id: object): """A peer that stringifies the id between request and cancel still cancels (same `coerce_request_id` path).""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) - s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) - server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording) + handler_interrupted = anyio.Event() async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: - await anyio.sleep_forever() + try: + await anyio.sleep_forever() + finally: + handler_interrupted.set() raise NotImplementedError async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: @@ -2184,15 +2200,12 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) with anyio.fail_after(5): - resp = await s2c_recv.receive() - assert isinstance(resp, SessionMessage) - assert isinstance(resp.message, JSONRPCError) - assert resp.message.id == request_id # response echoes the peer's id form verbatim - assert resp.message.error == ErrorData(code=0, message="Request cancelled") + await handler_interrupted.wait() # the cancel reached the handler despite the id form tg.cancel_scope.cancel() finally: - for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): - s.close() + c2s_send.close() + c2s_recv.close() + assert recording.sent == [] # cancelled: no response, in either id form @pytest.mark.anyio @@ -2245,11 +2258,7 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) ) - resp2 = await s2c_recv.receive() - assert isinstance(resp2, SessionMessage) - assert isinstance(resp2.message, JSONRPCError) - assert resp2.message.error == ErrorData(code=0, message="Request cancelled") - assert second_exited.is_set() + await second_exited.wait() # the cancel reached the surviving entry tg.cancel_scope.cancel() finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): @@ -2308,11 +2317,7 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) ) - resp2 = await s2c_recv.receive() - assert isinstance(resp2, SessionMessage) - assert isinstance(resp2.message, JSONRPCError) - assert resp2.message.error == ErrorData(code=0, message="Request cancelled") - assert second_exited.is_set() + await second_exited.wait() # the cancel reached the surviving entry tg.cancel_scope.cancel() finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): @@ -2366,11 +2371,12 @@ async def observe(ctx: Any, call_next: Any) -> Any: async with anyio.create_task_group() as tg: # pragma: no branch async def call() -> None: - with pytest.raises(MCPError): - await client.session.send_request( - CallToolRequest(params=CallToolRequestParams(name="t", arguments={})), - CallToolResult, - ) + # Never answered once cancelled; abandoned below. + await client.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="t", arguments={})), + CallToolResult, + ) + raise NotImplementedError # unreachable: the task is cancelled first tg.start_soon(call) await handler_started.wait() @@ -2381,10 +2387,8 @@ async def call() -> None: ) ) await cancel_observed.wait() - assert len(observed) == 1 - assert observed[0][0] == "notifications/cancelled" - assert observed[0][1]["requestId"] == request_id - assert observed[0][1]["reason"] == "user clicked stop" + tg.cancel_scope.cancel() # abandon the parked call (sends its own courtesy cancel) + assert observed[0] == ("notifications/cancelled", {"requestId": request_id, "reason": "user clicked stop"}) @pytest.mark.anyio From 706d0cefe0a02c99eadd22ad32136b9dc4a144d0 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:13 +0000 Subject: [PATCH 2/3] 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. --- docs/migration.md | 6 +-- examples/stories/streaming/README.md | 10 ++-- src/mcp/client/streamable_http.py | 40 ++++++++------- src/mcp/server/streamable_http.py | 51 ++++++++++++------- src/mcp/shared/jsonrpc_dispatcher.py | 37 +++++++++----- src/mcp/shared/message.py | 5 +- tests/client/test_streamable_http.py | 18 +++---- tests/interaction/_requirements.py | 23 +++++---- .../interaction/lowlevel/test_cancellation.py | 40 ++++++++++----- tests/interaction/lowlevel/test_wire.py | 4 +- .../transports/test_client_transport_http.py | 43 ---------------- .../transports/test_streamable_http.py | 38 ++++++++------ tests/shared/test_jsonrpc_dispatcher.py | 51 +++++++++++++++++++ 13 files changed, 214 insertions(+), 152 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 42c93cbdc7..7b5950e7cf 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1586,11 +1586,11 @@ Previously it also re-raised exceptions yielded by the transport onto the read s ### Cancelled requests are no longer answered -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). +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). -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. +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. -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. +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. ### `Server.run()` no longer takes a `stateless` flag diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md index e6bedb915a..be5f90d947 100644 --- a/examples/stories/streaming/README.md +++ b/examples/stories/streaming/README.md @@ -56,10 +56,12 @@ uv run python -m stories.streaming.client --http --server server_lowlevel OpenTelemetry instead of `notifications/message`. It is shown here because servers still need to support 2025-era clients during that window. Progress and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta. -- When a request is cancelled the server currently replies with - `ErrorData(code=0, message="Request cancelled")`; the spec says it should not - reply at all. The client never observes it (its awaiting task is already - cancelled), so this story does not assert on the reply. +- A cancelled request is not answered: no response follows + `notifications/cancelled`. (The 2025-era streamable HTTP transport is the one + exception - its wire ends a request only with a response, so it terminates + with a `-32800` `REQUEST_CANCELLED` error.) The client never observes any of + this - its awaiting task is already cancelled - so this story does not assert + on it. ## Spec diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 9c5bfdbc0d..c95cfcf50b 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -102,12 +102,12 @@ def __init__(self, url: str) -> None: # scheduling is arbitrary). Reused on outbound HTTP that carries no # per-message header (transport-internal GET/DELETE, and dispatcher-written # response/error POSTs that bypass the session's stamp), and consulted by - # `_apply_outbound_cancellation`. Cleared when an `initialize` message is + # `_consume_modern_cancellation`. Cleared when an `initialize` message is # dequeued so a probe-stamped value cannot leak onto the handshake. self._protocol_version_header: str | None = None # Every request's POST runs inside one of these so an outbound - # `notifications/cancelled` can abort it; see - # `_apply_outbound_cancellation`. Keys are verbatim-typed ("1" is not 1). + # `notifications/cancelled` at 2026 can abort it; see + # `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1). self._in_flight_posts: dict[RequestId, _InFlightPost] = {} def _prepare_headers(self) -> dict[str, str]: @@ -268,18 +268,20 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: await event_source.response.aclose() break - def _apply_outbound_cancellation(self, session_message: SessionMessage) -> bool: - """Apply an outbound `notifications/cancelled` to this transport; True means "do not POST". - - The dispatcher emits the frame as its abandon signal (it only ever names - one of our own request ids), so the named request's in-flight POST is - aborted at every era: its caller is gone and the stream must not linger - or resume. The POST's recorded era only decides whether the frame is also - POSTed - at 2026 the abort IS the cancellation signal and there are no - client-to-server notifications (swallow); at 2025 the frame is the signal, - so it is POSTed too. With no POST to consult, the cached negotiated version - decides; at 2026 the frame is swallowed even unmatched, so a late cancel - racing the response cannot leak onto the wire. + def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool: + """Translate an outbound `notifications/cancelled` at 2026; True means "do not POST". + + The 2026 wire defines no client-to-server notifications over streamable + HTTP: closing a request's response stream IS its cancellation signal. + The dispatcher still emits the courtesy frame as its abandon signal + (every outbound cancel names one of our own request ids - the spec + forbids cancelling a request the sender did not issue), so this + transport translates it: when the named request's POST is in flight, + that POST's own recorded era decides - abort-and-swallow at 2026, POST + the frame below it (where the frame is the signal and a disconnect + explicitly is not). With no POST to consult, the cached negotiated + version decides; at 2026 the frame is swallowed even unmatched, so a + late cancel racing the response cannot leak onto the wire. """ message = session_message.message if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"): @@ -287,9 +289,11 @@ def _apply_outbound_cancellation(self, session_message: SessionMessage) -> bool: request_id = cancelled_request_id_from_params(message.params) post = self._in_flight_posts.get(request_id) if request_id is not None else None if post is not None: + if not post.modern: + return False logger.debug("aborting in-flight POST for cancelled request %r", request_id) post.scope.cancel() - return post.modern + return True return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS async def _run_request_post( @@ -298,7 +302,7 @@ async def _run_request_post( post: _InFlightPost, request_id: RequestId, ) -> None: - """Run one request's POST inside its abort scope (see `_apply_outbound_cancellation`).""" + """Run one request's POST inside its abort scope (see `_consume_modern_cancellation`).""" try: with post.scope: await post_fn() @@ -544,7 +548,7 @@ async def post_writer( async def _handle_message(session_message: SessionMessage) -> None: message = session_message.message - if self._apply_outbound_cancellation(session_message): + if self._consume_modern_cancellation(session_message): return metadata = ( session_message.metadata diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 28a9cfb287..324fc3e04b 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -65,6 +65,13 @@ # whole session on a lazily-started `sse_writer`. See #1764. REQUEST_STREAM_BUFFER_SIZE: Final = 16 +# Error code answering a request that settled without a response (e.g. it was +# cancelled) on this 2025-era wire, which ends a request's stream only with a +# response. Mirrors LSP's RequestCancelled; not sent by the 2026 transports, where +# the spec forbids answering a cancelled request. See +# `StreamableHTTPServerTransport._terminate_unanswered_request`. +REQUEST_CANCELLED: Final = -32800 + # Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E) # Pattern ensures entire string contains only valid characters by using ^ and $ anchors SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$") @@ -252,7 +259,7 @@ def close_standalone_sse_stream(self) -> None: def _create_session_message( self, - message: JSONRPCMessage, + message: JSONRPCRequest, request: Request, request_id: RequestId, protocol_version: str, @@ -262,10 +269,10 @@ def _create_session_message( The close_sse_stream callbacks are only provided when the client supports resumability (protocol version >= 2025-11-25). Old clients can't resume if the stream is closed early because they didn't receive a priming event. - Every request carries `on_request_unanswered`, which ends its stream - when the request settles without a response. + Every request carries `on_request_unanswered`, so a request that settles + without a response is still terminated on this era's wire. """ - end_stream = partial(self._end_request_stream, request_id) + end_stream = partial(self._terminate_unanswered_request, message.id) # Only provide close callbacks when client supports resumability if self._event_store and is_version_at_least(protocol_version, "2025-11-25"): @@ -394,15 +401,19 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent: return event_data - async def _end_request_stream(self, request_id: RequestId) -> None: - """End a request's stream when it settled without a response (e.g. cancelled). + async def _terminate_unanswered_request(self, request_id: RequestId) -> None: + """Terminate a request that settled without a response (e.g. cancelled). - Only the send side, so the reader finishes as a normal end-of-stream: JSON - mode answers the POST with 202, SSE mode ends the event stream. Already - gone if `close_sse_stream()` polling or session teardown released it first. + The 2025-era wire ends a request's stream only with a response for its + id - and stores that response so a resuming client's replay terminates + too - so this era answers a cancelled request with `REQUEST_CANCELLED` + where the dispatcher itself stays silent (the 2026 transports MUST NOT + answer). It is written through the same ordered channel as the request's + other messages, so it cannot overtake anything already queued for it. """ - if streams := self._request_streams.get(request_id): # pragma: no branch - await streams[0].aclose() + assert self._write_stream is not None # a dispatched request implies connect() ran + error = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled") + await self._write_stream.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error))) async def _clean_up_memory_streams(self, request_id: RequestId) -> None: """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 # Process the message metadata = ServerMessageMetadata( request_context=request, - on_request_unanswered=partial(self._end_request_stream, request_id), + on_request_unanswered=partial(self._terminate_unanswered_request, message.id), ) session_message = SessionMessage(message, metadata=metadata) await writer.send(session_message) @@ -590,15 +601,19 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re else: # pragma: no cover logger.debug(f"received: {event_message.message.method}") + # At this point we should have a response if response_message: # Create JSON response response = self._create_json_response(response_message) - else: - # The stream ended without a response: the request settled - # unanswered (e.g. it was cancelled). End the POST with - # an empty 202 Accepted rather than leaving it open. - response = self._create_json_response(None, HTTPStatus.ACCEPTED) - await response(scope, receive, send) + await response(scope, receive, send) + else: # pragma: no cover + # This shouldn't happen in normal operation + logger.error("No response message received before stream closed") + response = self._create_error_response( + "Error processing request: No response received", + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + await response(scope, receive, send) except Exception: # pragma: no cover logger.exception("Error processing JSON response") response = self._create_error_response( diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index f752aeaeff..0d9467ffeb 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -704,6 +704,7 @@ async def _handle_request( instead, and `_settle_unanswered` tells the transport. """ answer_write_started = False + handler_failure: BaseException | None = None # re-raised once the request settles try: with scope: try: @@ -724,10 +725,6 @@ async def _handle_request( # would break JSON-RPC. answer_write_started = True await self._write_result(req.id, result) - # Reached unanswered: the handler saw the cancel (any mode), or the - # scope absorbed the interrupt at __exit__. - if not answer_write_started: - await self._settle_unanswered(dctx) except anyio.get_cancelled_exc_class(): # Shutdown: answer the request so the peer isn't left waiting - unless # an answer write already started (it may have reached the transport; @@ -744,19 +741,24 @@ async def _handle_request( raise except Exception as e: error = handler_exception_to_error_data(e) - unmapped = error is None - if unmapped: + if error is None: logger.exception("handler for %r raised", req.method) # TODO(L58): code=0 pins existing-server compat; JSON-RPC says # INTERNAL_ERROR. Revisit per the suite's divergence entry. error = ErrorData(code=0, message=str(e)) + if self._raise_handler_exceptions: + handler_failure = e # A cancel silences only the wire; the failure stays as visible as before. - if dctx.cancel_requested.is_set(): - await self._settle_unanswered(dctx) - else: + if not dctx.cancel_requested.is_set(): + answer_write_started = True await self._write_error(req.id, error) - if unmapped and self._raise_handler_exceptions: - raise + # The one place a cancelled request settles: the handler is done (any + # mode) with nothing written. A peer-interrupt cancel is absorbed at + # scope __exit__ and lands here too. + if not answer_write_started: + await self._settle_unanswered(dctx) + if handler_failure is not None: + raise handler_failure # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id. def _allocate_id(self) -> int: @@ -781,12 +783,19 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None: async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None: """Run the transport's `on_request_unanswered` hook: this request settled with no response. - A shared-channel wire (stdio) has none; legacy streamable HTTP uses it - to complete the request's still-open POST. + The dispatcher writes nothing for it; a transport whose wire must still + end the request (2025-era streamable HTTP) does so from this hook. A + raising hook is contained here, like the other callback boundaries. """ metadata = dctx.message_metadata - if isinstance(metadata, ServerMessageMetadata) and metadata.on_request_unanswered is not None: + if not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None: + return + try: await metadata.on_request_unanswered() + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("on_request_unanswered dropped: connection closing") + except Exception: + logger.exception("on_request_unanswered hook raised") async def _final_write( self, diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py index bad0865ded..5ce255d15e 100644 --- a/src/mcp/shared/message.py +++ b/src/mcp/shared/message.py @@ -41,8 +41,9 @@ class ServerMessageMetadata: close_sse_stream: CloseSSEStreamCallback | None = None # Callback to close the standalone GET SSE stream (for unsolicited notifications) close_standalone_sse_stream: CloseSSEStreamCallback | None = None - # Callback for a request that settles without a response (e.g. cancelled), - # so the transport can end the per-request state a response would have closed. + # Callback the dispatcher runs when this request settles without a response + # (e.g. it was cancelled), for a transport whose wire must still end the + # request even though no response is written. on_request_unanswered: Callable[[], Awaitable[None]] | None = None diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index dec2e5bb6b..d21f520daf 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -261,12 +261,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.anyio @pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"]) -async def test_legacy_cancelled_frame_posts_and_releases_the_abandoned_stream(stamped_version: str | None) -> None: +async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None: """Below 2026 — or before any stamped POST has revealed the version — the frame is - the spec's cancellation signal, so it POSTs; the abandoned request's own response - stream is also released rather than left parked (or resuming) for an answer the - caller no longer wants. The frame, not the disconnect, carries the cancel: a 2025 - server treats the disconnect as nothing.""" + the spec's cancellation signal: it POSTs, and the request's stream stays open + (a 2025 disconnect is explicitly not a cancel).""" parked = _ParkedSSEStream() posted: list[dict[str, Any]] = [] frame_posted = anyio.Event() @@ -295,7 +293,8 @@ async def test_legacy_cancelled_frame_posts_and_releases_the_abandoned_stream(st ) ) await frame_posted.wait() - await parked.closed.wait() # the abandoned request's stream is torn down, not left open + # Checked before teardown: exiting the transport cancels the parked POST. + assert not parked.closed.is_set() assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"] @@ -390,8 +389,8 @@ async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None: """The translation follows the era the NAMED request was sent under, not the cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation - semantics (frame on the wire) even after a later message flips the negotiated - version to 2026 - the abandoned stream is released either way.""" + semantics (frame on the wire, stream left open) even after a later message + flips the negotiated version to 2026.""" parked = _ParkedSSEStream() posted: list[dict[str, Any]] = [] frame_posted = anyio.Event() @@ -434,7 +433,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) ) await frame_posted.wait() - await parked.closed.wait() # the abandoned request's stream is released too + # Checked before teardown: exiting the transport cancels the parked POST. + assert not parked.closed.is_set() assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"] diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 01c4b0d5bb..316559ab09 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -540,8 +540,10 @@ def __post_init__(self) -> None: "receiver does not send a response for the cancelled request - no result and no error." ), note=( - "Over streamable HTTP the cancelled request's own POST still completes; see " - "transport:streamable-http:cancelled-request-completes." + "The 2025-era streamable HTTP wire has no way to end a request but a response, so that " + "one transport terminates the settled request with REQUEST_CANCELLED (-32800) instead of " + "staying silent; see transport:streamable-http:cancelled-request-terminated. The 2026-07-28 " + "MUST NOT applies to stdio and the 2026 wire, where nothing is sent." ), arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), @@ -2555,17 +2557,20 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.", ), - "transport:streamable-http:cancelled-request-completes": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#behavior-requirements", + "transport:streamable-http:cancelled-request-terminated": Requirement( + source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", behavior=( - "A request cancelled through notifications/cancelled leaves no response to send, but its POST " - "still completes: an empty 202 in JSON-response mode, an event stream that ends with no " - "response event in SSE mode - the per-request stream is not left open." + "A request cancelled through notifications/cancelled is terminated with a REQUEST_CANCELLED " + "(-32800) error response, completing its POST - the JSON body in JSON-response mode, the " + "final event of its stream in SSE mode." ), transports=("streamable-http",), note=( - "Only streamable HTTP holds a per-request stream that a response would otherwise close; the " - "unanswered-request signal is what releases it." + "Deliberate SHOULD-level divergence, era-scoped: the 2025-era wire ends a request's stream " + "only with a response for its id (and stores it so a resuming client's replay terminates " + "too), so this transport answers where the dispatcher writes nothing. Written through the " + "same ordered channel as the request's other messages, so it cannot overtake anything " + "already queued for the request." ), ), "transport:streamable-http:stateless": Requirement( diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 923ef8cf41..74c32e304b 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -15,6 +15,7 @@ REQUEST_TIMEOUT, CallToolResult, EmptyResult, + ErrorData, Implementation, InitializeResult, JSONRPCNotification, @@ -30,6 +31,7 @@ from mcp import Client, MCPError from mcp.client import ClientRequestContext, ClientSession, IncomingMessage from mcp.server import Server, ServerRequestContext +from mcp.server.streamable_http import REQUEST_CANCELLED from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage from tests._stamp import Unstamp @@ -38,15 +40,22 @@ pytestmark = pytest.mark.anyio +_LEGACY_HTTP_TERMINATOR = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled") +"""The one wire where a cancelled request is still answered: the 2025-era streamable HTTP +transport ends a request only with a response, so it terminates the settled request with +`REQUEST_CANCELLED`. Every other transport sends nothing at all.""" -async def _record_result(client: Client, outcomes: list[object]) -> None: - """Await the doomed `block` call and record a result, should one ever arrive. - Nothing ever arrives for it, so this parks until the task is abandoned; an error response - would surface here as an uncaught MCPError and fail the test. +async def _await_doomed_call(client: Client, outcomes: list[object]) -> None: + """Await the doomed `block` call and record whatever, if anything, the caller receives. + + On the stream transports nothing ever arrives, so this parks until the task is abandoned; + over legacy streamable HTTP the transport's terminator arrives as an MCPError. """ - outcomes.append(await client.call_tool("block", {})) - raise NotImplementedError # unreachable: the task is abandoned first + try: + outcomes.append(await client.call_tool("block", {})) + except MCPError as exc: + outcomes.append(exc.error) @requirement("protocol:cancel:in-flight") @@ -57,7 +66,8 @@ async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None: The cancellation is scripted by hand while a sibling task still awaits the call, which is something a well-behaved sender never does (per spec it stops waiting once it cancels). That lets the test prove the negative: after the handler is interrupted and the connection has - quiesced, no server response has reached the still-parked call. + quiesced, no server response has reached the still-parked call - except the legacy + streamable HTTP terminator (`_LEGACY_HTTP_TERMINATOR`). """ started = anyio.Event() handler_cancelled = anyio.Event() @@ -81,7 +91,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: with anyio.fail_after(5): async with anyio.create_task_group() as task_group: # pragma: no branch - task_group.start_soon(_record_result, client, outcomes) + task_group.start_soon(_await_doomed_call, client, outcomes) await started.wait() await client.session.send_notification( types.CancelledNotification( @@ -91,7 +101,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara await handler_cancelled.wait() # Let anything the server was going to send be delivered before checking. await anyio.wait_all_tasks_blocked() - assert outcomes == [] + assert outcomes in ([], [_LEGACY_HTTP_TERMINATOR]) task_group.cancel_scope.cancel() # abandon the call if it is still parked @@ -130,7 +140,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: with anyio.fail_after(5): async with anyio.create_task_group() as task_group: - task_group.start_soon(_record_result, client, list[object]()) + task_group.start_soon(_await_doomed_call, client, list[object]()) await started.wait() await client.session.send_notification( types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0])) @@ -400,8 +410,9 @@ async def call_and_abandon() -> None: with anyio.fail_after(5): await handler_cancelled.wait() - # Let the abandoned call's late error response (sent on the legacy arms) arrive and be - # dropped while the client is still open, so teardown never races its delivery. + # Let anything still owed the abandoned call (the REQUEST_CANCELLED terminator over + # legacy streamable HTTP; nothing elsewhere) arrive and be dropped while the client is + # still open, so teardown never races its delivery. await anyio.wait_all_tasks_blocked() result = await client.call_tool("echo", {}) assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")])) @@ -467,8 +478,9 @@ async def survivor_call() -> None: await doomed_cancelled.wait() release_survivor.set() - # Let the abandoned call's late error response (sent on the legacy arms) arrive and be - # dropped while the client is still open, so teardown never races its delivery. + # Let anything still owed the abandoned call (the REQUEST_CANCELLED terminator over + # legacy streamable HTTP; nothing elsewhere) arrive and be dropped while the client is + # still open, so teardown never races its delivery. await anyio.wait_all_tasks_blocked() assert [unstamped(result) for result in results] == snapshot( diff --git a/tests/interaction/lowlevel/test_wire.py b/tests/interaction/lowlevel/test_wire.py index b3d286ca1d..8ee05fec38 100644 --- a/tests/interaction/lowlevel/test_wire.py +++ b/tests/interaction/lowlevel/test_wire.py @@ -353,8 +353,8 @@ async def call_and_abandon() -> None: with anyio.fail_after(5): await handler_cancelled.wait() - # Let the cancelled call's late error response arrive and be dropped while the client - # is still open, so teardown never races its delivery. + # Let any in-flight delivery for the abandoned call settle while the client is still + # open, so teardown never races it (nothing arrives here: a cancelled request is not answered). await anyio.wait_all_tasks_blocked() call, cancel = [message.message for message in recording.sent] diff --git a/tests/interaction/transports/test_client_transport_http.py b/tests/interaction/transports/test_client_transport_http.py index 6c7b1eab49..625fcaad8d 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -350,46 +350,3 @@ async def call_and_abandon() -> None: cancels = [p for p in posts if p.get("method") == "notifications/cancelled"] assert len(block_calls) == 1 assert [c["params"]["requestId"] for c in cancels] == [block_calls[0]["id"]] - - -@requirement("client-transport:http:cancel-posts-frame") -async def test_at_2025_abandoning_a_resumable_call_does_not_reconnect() -> None: - """Abandoning a call against a resumable (event-store) server tears the call's stream down. - - The server ends a cancelled request's stream without a response; a client that kept the - abandoned stream open would take the priming event's id back to a Last-Event-ID reconnect for - an answer that will never come. Releasing the stream on abandonment means no such GET - follows: the only requests after the tool call are the cancel frame and the closing DELETE. - """ - handler_started = anyio.Event() - handler_cancelled = anyio.Event() - requests: list[tuple[str, str | None]] = [] - - async def record(request: httpx2.Request) -> None: - requests.append((request.method, request.headers.get("last-event-id"))) - - server = _blocking_server(handler_started, handler_cancelled) - async with mounted_app(server, event_store=SequencedEventStore(), retry_interval=0, on_request=record) as ( - http, - _, - ): - async with client_via_http(http) as client: - abandon = anyio.CancelScope() - - async def call_and_abandon() -> None: - with abandon: - await client.call_tool("block", {}) - raise NotImplementedError # unreachable: the call never resolves - - async with anyio.create_task_group() as tg: - tg.start_soon(call_and_abandon) - with anyio.fail_after(5): - await handler_started.wait() - abandon.cancel() - with anyio.fail_after(5): - await handler_cancelled.wait() - # Quiesce so any reconnect the transport was going to make would have been issued. - await anyio.wait_all_tasks_blocked() - - assert [method for method, _ in requests].count("DELETE") == 1 - assert all(last_event_id is None for _, last_event_id in requests) diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py index ce2857d50d..e1f16dc5f0 100644 --- a/tests/interaction/transports/test_streamable_http.py +++ b/tests/interaction/transports/test_streamable_http.py @@ -16,6 +16,8 @@ CallToolResult, ElicitRequestParams, ElicitResult, + ErrorData, + JSONRPCError, JSONRPCMessage, JSONRPCRequest, LoggingMessageNotification, @@ -23,6 +25,7 @@ ResourceUpdatedNotification, ResourceUpdatedNotificationParams, TextContent, + jsonrpc_message_adapter, ) from pydantic import BaseModel @@ -30,6 +33,7 @@ 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 ( base_headers, @@ -181,15 +185,15 @@ async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> assert [params.message for params in asked] == snapshot(["Proceed?"]) -@requirement("transport:streamable-http:cancelled-request-completes") +@requirement("transport:streamable-http:cancelled-request-terminated") @pytest.mark.parametrize("json_response", [True, False], ids=["json-response", "sse-response"]) -async def test_cancelled_request_still_completes_its_post(json_response: bool) -> None: - """A cancelled request has no response to send, yet its POST is not left open. +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 dispatcher signals that the request settled unanswered and the transport ends the - per-request stream: an empty 202 in JSON-response mode, a cleanly-ended event stream carrying - no response event in SSE mode. Driven with raw httpx2 because the observable is the HTTP - exchange itself, which a Client abandoning its own call would tear down first. + 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() @@ -216,8 +220,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> "method": "notifications/cancelled", "params": {"requestId": call_request_id}, } - call_status: list[int] = [] - call_messages: list[JSONRPCMessage] = [] + call_answers: list[JSONRPCMessage] = [] async with mounted_app(server, json_response=json_response) as (http, _manager): if json_response: @@ -236,12 +239,10 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> async def post_call() -> None: if json_response: response = await http.post("/mcp", json=call_body, headers=base_headers(session_id=session_id)) - call_status.append(response.status_code) - assert response.content == b"" + call_answers.append(jsonrpc_message_adapter.validate_json(response.content)) else: - response, messages = await post_jsonrpc(http, call_body, session_id=session_id) - call_status.append(response.status_code) - call_messages.extend(messages) + _, 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 @@ -252,5 +253,10 @@ async def post_call() -> None: await handler_cancelled.wait() # The call's POST must now complete on its own; the task group waits for it. - assert call_status == [202 if json_response else 200] - assert call_messages == [] + assert call_answers == [ + JSONRPCError( + jsonrpc="2.0", + id=call_request_id, + error=ErrorData(code=REQUEST_CANCELLED, message="Request cancelled"), + ) + ] diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 737d289d78..51f7f7c820 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -240,6 +240,57 @@ async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) - assert await _drive_cancelled_request(on_request, done=handler_failed) == (_CONTROL_ONLY, [1]) +@pytest.mark.anyio +@pytest.mark.parametrize( + "hook_error", + [RuntimeError("hook failed"), anyio.ClosedResourceError()], + ids=["hook-bug", "connection-closing"], +) +async def test_a_raising_unanswered_hook_is_contained(hook_error: Exception): + """A transport `on_request_unanswered` hook that raises is contained, not fatal: the + dispatcher keeps serving (the control request written after it is still answered).""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording) + handler_exited = anyio.Event() + + async def failing_hook() -> None: + raise hook_error + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "control": + return {"control": True} + try: + await anyio.sleep_forever() + finally: + handler_exited.set() + raise NotImplementedError + + async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: + pass + + request_1 = SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None), + metadata=ServerMessageMetadata(on_request_unanswered=failing_hook), + ) + cancel = JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}) + control = SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="control", params=None)) + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, on_notify) + with anyio.fail_after(5): + await c2s_send.send(request_1) + await c2s_send.send(SessionMessage(message=cancel)) + await handler_exited.wait() + await c2s_send.send(control) + await anyio.wait_all_tasks_blocked() + tg.cancel_scope.cancel() + finally: + c2s_send.close() + c2s_recv.close() + assert [m.message for m in recording.sent] == _CONTROL_ONLY + + @pytest.mark.anyio async def test_send_raw_request_raises_connection_closed_when_read_stream_eofs_mid_await(): """A blocked send_raw_request is woken with CONNECTION_CLOSED when run() exits.""" From 5dc226b51f7ba5c0c62ce7e0ef6f6f2b1ddbedf2 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:32:04 +0000 Subject: [PATCH 3/3] Correct the requirements manifest and README after the rework The client-transport revert left the cancel-posts-frame requirement describing a stream release the client no longer performs; restore its main-branch wording. Record the era-scoped -32800 answer on the legacy HTTP wire as typed Divergence data on the protocol requirement, as the manifest header requires for a test-pinned gap, and give the transport entry an sdk source since the response is an SDK choice rather than a spec mandate. Reword the streaming story's See-also line, which pointed at a cancellation error path that no longer exists. No-Verification-Needed: manifest and example-README text only, no runtime surface --- examples/stories/streaming/README.md | 5 ++-- tests/interaction/_requirements.py | 35 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md index be5f90d947..c485399b71 100644 --- a/examples/stories/streaming/README.md +++ b/examples/stories/streaming/README.md @@ -71,5 +71,6 @@ uv run python -m stories.streaming.client --http --server server_lowlevel ## See also -`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the -cancellation error path), `tools/` (the basics this builds on). +`parallel_calls/` (concurrent in-flight calls), `error_handling/` (error +surfaces: `is_error` results vs protocol errors), `tools/` (the basics this +builds on). diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 316559ab09..a26f7d9983 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -539,11 +539,15 @@ def __post_init__(self) -> None: "A cancellation notification for an in-flight request stops the server-side handler, and the " "receiver does not send a response for the cancelled request - no result and no error." ), - note=( - "The 2025-era streamable HTTP wire has no way to end a request but a response, so that " - "one transport terminates the settled request with REQUEST_CANCELLED (-32800) instead of " - "staying silent; see transport:streamable-http:cancelled-request-terminated. The 2026-07-28 " - "MUST NOT applies to stdio and the 2026 wire, where nothing is sent." + divergence=Divergence( + note=( + "The 2025-era streamable HTTP transport still answers a cancelled request, with a " + "REQUEST_CANCELLED (-32800) error - deliberate and era-scoped: that wire ends a request's " + "stream only with a response for its id, so silence would leave the POST (and any " + "resuming client's replay) open. Every other transport sends nothing, and the " + "2026-07-28 MUST NOT applies only there. Retires with the legacy transport; see " + "transport:streamable-http:cancelled-request-terminated." + ), ), arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), @@ -2558,7 +2562,7 @@ def __post_init__(self) -> None: note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.", ), "transport:streamable-http:cancelled-request-terminated": Requirement( - source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", + source="sdk", behavior=( "A request cancelled through notifications/cancelled is terminated with a REQUEST_CANCELLED " "(-32800) error response, completing its POST - the JSON body in JSON-response mode, the " @@ -2566,11 +2570,11 @@ def __post_init__(self) -> None: ), transports=("streamable-http",), note=( - "Deliberate SHOULD-level divergence, era-scoped: the 2025-era wire ends a request's stream " - "only with a response for its id (and stores it so a resuming client's replay terminates " - "too), so this transport answers where the dispatcher writes nothing. Written through the " - "same ordered channel as the request's other messages, so it cannot overtake anything " - "already queued for the request." + "An SDK choice, not spec-mandated (the spec-side gap is the Divergence on " + "protocol:cancel:in-flight): this era's wire ends a request's stream only with a response " + "for its id, and stores it so a resuming client's replay terminates too. The terminator is " + "written through the same ordered channel as the request's other messages, so it cannot " + "overtake anything already queued for the request." ), ), "transport:streamable-http:stateless": Requirement( @@ -3395,17 +3399,12 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow", behavior=( "At 2025-era revisions, abandoning an in-flight request POSTs exactly one " - "notifications/cancelled naming its request id, and releases the abandoned request's own " - "response stream so it neither stays parked nor resumes." + "notifications/cancelled naming its request id." ), transports=("streamable-http",), removed_in="2026-07-28", superseded_by="client-transport:http:cancel-closes-stream", - note=( - "HTTP-only by nature: pins that the frame travels as its own POST on the legacy HTTP wire. " - "The frame, not the released stream, is the signal - a 2025 server treats the disconnect " - "as nothing." - ), + note="HTTP-only by nature: pins that the frame travels as its own POST on the legacy HTTP wire.", ), "client-transport:http:concurrent-streams": Requirement( source="sdk",