Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}` - 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).

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

Comment thread
claude[bot] marked this conversation as resolved.
### `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.
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions examples/stories/streaming/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -69,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).
34 changes: 31 additions & 3 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$")
Expand Down Expand Up @@ -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,
Expand All @@ -262,7 +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`, so a request that settles
without a response is still terminated on this era's wire.
"""
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"):

Expand All @@ -276,9 +286,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)

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

return event_data

async def _terminate_unanswered_request(self, request_id: RequestId) -> None:
"""Terminate a request that settled without a response (e.g. cancelled).

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.
"""
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."""
if request_id in self._request_streams: # pragma: no branch
Expand Down Expand Up @@ -555,7 +580,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._terminate_unanswered_request, message.id),
)
session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message)
try:
Expand Down
76 changes: 51 additions & 25 deletions src/mcp/shared/jsonrpc_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -696,9 +698,13 @@ 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
handler_failure: BaseException | None = None # re-raised once the request settles
try:
with scope:
try:
Expand All @@ -711,27 +717,21 @@ 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():
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# 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)
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,
Expand All @@ -741,15 +741,24 @@ 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:
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.
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
error = ErrorData(code=0, message=str(e))
if self._raise_handler_exceptions:
raise
handler_failure = e
# A cancel silences only the wire; the failure stays as visible as before.
if not dctx.cancel_requested.is_set():
answer_write_started = True
await self._write_error(req.id, error)
# 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:
Expand All @@ -771,6 +780,23 @@ 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.

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 not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None:
return
try:
await metadata.on_request_unanswered()
Comment thread
claude[bot] marked this conversation as resolved.
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,
write: Callable[[], Awaitable[None]],
Expand Down
Loading
Loading