Stop answering cancelled requests by maxisbey · Pull Request #3188 · modelcontextprotocol/python-sdk · GitHub
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
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
Loading
Loading