From 69e6cd38a919be2c39503c052ff1ae349e9f63c3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:10:35 +0000 Subject: [PATCH 1/8] Make client-side cancellation work over the 2026 transports The modern session stamp suppressed the courtesy notifications/cancelled for every request, so abandoning a request over 2026 streamable HTTP left the POST open and the server running, and over 2026 stream-pair transports never sent the frame the spec requires. The frame is now the dispatcher's uniform abandon signal: stream transports write it (the 2026 stdio cancellation spelling), while the streamable-HTTP transport translates it into aborting the named request's own in-flight POST - closing the response stream is that wire's cancellation signal, and no client-to-server notification ever POSTs at 2026. Each POST records the era it was sent under so a late cancel is interpreted per the named request, not whatever was negotiated since; pre-2026 wires still POST the frame (a disconnect is explicitly not a cancel there). The negotiation methods keep their cancellation opt-out on every path. Callers can also supply their own request id via CallOptions["request_id"] on both dispatchers - groundwork for demultiplexing subscriptions/listen streams, whose id must be known before the result arrives. Ids reach the peer verbatim ("7" stays a string), collide loudly only for the caller who chose them (minting skips occupied keys), and share one coerced collision domain so the in-memory dispatcher raises exactly where the wire one would. --- src/mcp/client/session.py | 11 +- src/mcp/client/streamable_http.py | 111 +++++- src/mcp/shared/direct_dispatcher.py | 33 +- src/mcp/shared/dispatcher.py | 27 ++ src/mcp/shared/jsonrpc_dispatcher.py | 76 +++-- tests/client/test_session.py | 83 +++-- tests/client/test_streamable_http.py | 435 +++++++++++++++++++++++- tests/shared/test_dispatcher.py | 113 ++++++ tests/shared/test_jsonrpc_dispatcher.py | 50 ++- 9 files changed, 848 insertions(+), 91 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 804180e05..5c09304e4 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -93,7 +93,16 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: meta[PROTOCOL_VERSION_META_KEY] = protocol_version meta[CLIENT_INFO_META_KEY] = client_info meta[CLIENT_CAPABILITIES_META_KEY] = capabilities - opts["cancel_on_abandon"] = False + # `cancel_on_abandon` stays at the dispatcher default (True): the + # courtesy `notifications/cancelled` is the abandon signal. On the + # stream transports it is the 2026 wire's cancellation spelling; the + # streamable-HTTP transport translates it into aborting the request's + # own POST instead of writing it (the 2026 HTTP wire has no + # client-to-server notifications - closing the stream is the signal). + # The negotiation methods still opt out, mirroring `_preconnect_stamp`: + # the spec forbids cancelling them. + if data["method"] in ("initialize", "server/discover"): + opts["cancel_on_abandon"] = False headers = opts.setdefault("headers", {}) headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version headers[MCP_METHOD_HEADER] = data["method"] diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index f28eb7c7a..09e5048cc 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -26,6 +26,7 @@ RequestId, jsonrpc_message_adapter, ) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import ValidationError from mcp.client._transport import TransportStreams @@ -33,6 +34,7 @@ from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params from mcp.shared.message import ClientMessageMetadata, SessionMessage logger = logging.getLogger(__name__) @@ -70,6 +72,19 @@ class RequestContext: read_stream_writer: StreamWriter +@dataclass(slots=True) +class _InFlightPost: + """A request POST in flight: its abort scope and the era it was sent under. + + `modern` is the negotiated-version cache as of this request's dequeue, so a + later cancel frame is interpreted under the era the request actually ran + with, not whatever the cache says by then. + """ + + scope: anyio.CancelScope + modern: bool + + class StreamableHTTPTransport: """StreamableHTTP client transport implementation.""" @@ -81,11 +96,18 @@ def __init__(self, url: str) -> None: """ self.url = url self.session_id: str | None = None - # Captured from each stamped POST's metadata. Reused on outbound HTTP that carries - # no per-message header (transport-internal GET/DELETE, and dispatcher-written - # response/error/cancel POSTs that bypass the session's stamp). Cleared when an - # `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake. + # Captured from each stamped message's metadata, synchronously in the + # post_writer loop so the cache always reflects wire order (a POST task's + # 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 + # 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). + self._in_flight_posts: dict[RequestId, _InFlightPost] = {} def _prepare_headers(self) -> dict[str, str]: """Build MCP-specific request headers for any outbound HTTP request. @@ -93,9 +115,9 @@ def _prepare_headers(self) -> dict[str, str]: These are merged with the ``httpx.AsyncClient`` defaults (these take precedence). The cached ``MCP-Protocol-Version`` is included whenever present so messages that don't pass through the session's stamp — - response/error/cancel POSTs, transport-internal GET/DELETE — still - carry the negotiated version. Per-message headers are layered on top - by the caller. + response/error POSTs, legacy cancel frames, transport-internal + GET/DELETE — still carry the negotiated version. Per-message headers + are layered on top by the caller. """ headers: dict[str, str] = { "accept": "application/json, text/event-stream", @@ -245,19 +267,57 @@ 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. + """ + message = session_message.message + if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"): + return False + 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 self._protocol_version_header in MODERN_PROTOCOL_VERSIONS + + async def _run_request_post( + self, + post_fn: Callable[[], Awaitable[None]], + post: _InFlightPost, + request_id: RequestId, + ) -> None: + """Run one request's POST inside its abort scope (see `_consume_modern_cancellation`).""" + try: + with post.scope: + await post_fn() + finally: + # Identity-guarded: a reused id may already have a successor + # registered while this task unwinds - popping by key alone would + # evict the live entry and leave the new POST unabortable. + if self._in_flight_posts.get(request_id) is post: + del self._in_flight_posts[request_id] + async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" message = ctx.session_message.message - is_initialization = self._is_initialization_request(message) - if is_initialization: - # `initialize` is the negotiation, not a "subsequent request" — discard any - # probe-stamped value so the discover→fallback path can't leak it onto the handshake. - self._protocol_version_header = None headers = self._prepare_headers() if ctx.metadata is not None and ctx.metadata.headers is not None: headers.update(ctx.metadata.headers) - if MCP_PROTOCOL_VERSION_HEADER in ctx.metadata.headers: - self._protocol_version_header = ctx.metadata.headers[MCP_PROTOCOL_VERSION_HEADER] async with ctx.client.stream( "POST", @@ -302,7 +362,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: await ctx.read_stream_writer.send(session_message) return - if is_initialization: + if self._is_initialization_request(message): self._maybe_extract_session_id_from_response(response) # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: @@ -455,6 +515,8 @@ async def post_writer( async def _handle_message(session_message: SessionMessage) -> None: message = session_message.message + if self._consume_modern_cancellation(session_message): + return metadata = ( session_message.metadata if isinstance(session_message.metadata, ClientMessageMetadata) @@ -470,6 +532,15 @@ async def _handle_message(session_message: SessionMessage) -> None: if self._is_initialized_notification(message): start_get_stream() + if self._is_initialization_request(message): + # `initialize` is the negotiation, not a "subsequent request" — discard any + # probe-stamped value so the discover→fallback path can't leak it onto the handshake. + self._protocol_version_header = None + elif metadata is not None and metadata.headers is not None: + stamped_version = metadata.headers.get(MCP_PROTOCOL_VERSION_HEADER) + if stamped_version is not None: + self._protocol_version_header = stamped_version + ctx = RequestContext( client=client, session_id=self.session_id, @@ -486,7 +557,15 @@ async def handle_request_async(): # If this is a request, start a new task to handle it if isinstance(message, JSONRPCRequest): - tg.start_soon(handle_request_async) + # Register the abort scope before the spawn: the next + # message through this loop can already be the abandon + # signal for this id, ahead of the task ever running. + post = _InFlightPost( + scope=anyio.CancelScope(), + modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS, + ) + self._in_flight_posts[message.id] = post + tg.start_soon(self._run_request_post, handle_request_async, post, message.id) else: await handle_request_async() diff --git a/src/mcp/shared/direct_dispatcher.py b/src/mcp/shared/direct_dispatcher.py index fd3e69d49..62c74b808 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,7 @@ from pydantic import ValidationError from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT +from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT, coerce_request_id from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext @@ -56,7 +56,8 @@ class _DirectDispatchContext: _back_request: _Request _back_notify: _Notify request_id: RequestId | None = None - """A dispatcher-synthesized id for requests; `None` for notifications.""" + """The caller-supplied `CallOptions["request_id"]`, else a dispatcher-synthesized + id for requests; `None` for notifications.""" message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework """Always `None`: in-memory dispatch attaches no transport metadata.""" _on_progress: ProgressFnT | None = None @@ -106,6 +107,7 @@ def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: self._on_request: OnRequest | None = None self._on_notify: OnNotify | None = None self._next_id = 0 + self._in_flight_ids: set[RequestId] = set() self._ready = anyio.Event() self._close_event = anyio.Event() self._running = False @@ -227,9 +229,28 @@ async def _dispatch_request( # waiting on a peer whose run() has not started yet. await self._wait_ready() assert self._on_request is not None - # Synthesize an id: the DispatchContext contract reserves None for notifications. - self._next_id += 1 - dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=self._next_id) + supplied_id = opts.get("request_id") + if supplied_id is not None: + request_id: RequestId = supplied_id + # Collisions use the same coerced domain as JSONRPCDispatcher's + # pending keys, so this in-memory stand-in raises for exactly + # the ids the wire dispatcher would; the context still sees + # the verbatim value. + in_flight_key = coerce_request_id(request_id) + if in_flight_key in self._in_flight_ids: + raise ValueError(f"request id {request_id!r} is already in flight") + else: + # Synthesize an id (the DispatchContext contract reserves None + # for notifications), minting past any key a supplied id + # occupies: the collision error is reserved for the caller + # who actually chose the id. + self._next_id += 1 + while self._next_id in self._in_flight_ids: + self._next_id += 1 + request_id = self._next_id + in_flight_key = request_id + self._in_flight_ids.add(in_flight_key) + dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=request_id) try: return await self._on_request(dctx, method, params) except MCPError: @@ -247,6 +268,8 @@ async def _dispatch_request( raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e logger.exception("request handler raised") raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None + finally: + self._in_flight_ids.discard(in_flight_key) except TimeoutError: raise MCPError( code=REQUEST_TIMEOUT, diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index de83189f1..16360d314 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -34,11 +34,26 @@ "OnRequest", "Outbound", "ProgressFnT", + "coerce_request_id", ] TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) +def coerce_request_id(request_id: RequestId) -> RequestId: + """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). + + This is the collision/correlation domain dispatchers share: "7" and 7 are one + id for correlation purposes, even where the wire carries the verbatim value. + """ + if isinstance(request_id, str): + try: + return int(request_id) + except ValueError: + pass + return request_id + + class ProgressFnT(Protocol): """Callback invoked when a progress notification arrives for a pending request.""" @@ -51,6 +66,18 @@ class CallOptions(TypedDict, total=False): All keys are optional. Dispatchers ignore keys they do not understand. """ + request_id: RequestId + """Send the request under this caller-supplied id instead of a dispatcher-minted one. + + The peer sees the value verbatim ("7" stays a string). A value that collides + with one of the sender's own in-flight request ids raises `ValueError`. + Callers that need to know a request's id before its result arrives (a + `subscriptions/listen` stream is demultiplexed by it) mint their own ids + here; string ids that don't parse as integers can never collide with the + dispatcher's minted sequence. Per the class contract, dispatchers that + predate this key ignore it and mint as usual. + """ + timeout: float """Seconds to wait for a result before raising and sending `notifications/cancelled`.""" diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 64fcd3298..793c59bc7 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -39,7 +39,15 @@ from mcp.shared._compat import resync_tracer from mcp.shared._otel import inject_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream -from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest, ProgressFnT +from mcp.shared.dispatcher import ( + CallOptions, + DispatchContext, + Dispatcher, + OnNotify, + OnRequest, + ProgressFnT, + coerce_request_id, +) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import ( ClientMessageMetadata, @@ -49,7 +57,12 @@ ) from mcp.shared.transport_context import TransportContext -__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data", "progress_token_from_params"] +__all__ = [ + "JSONRPCDispatcher", + "cancelled_request_id_from_params", + "handler_exception_to_error_data", + "progress_token_from_params", +] logger = logging.getLogger(__name__) @@ -93,14 +106,13 @@ def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToke return None -def _coerce_id(request_id: RequestId) -> RequestId: - """Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK).""" - if isinstance(request_id, str): - try: - return int(request_id) - except ValueError: - pass - return request_id +def cancelled_request_id_from_params(params: Mapping[str, Any] | None) -> RequestId | None: + """Read `params.requestId` from a `notifications/cancelled`; reject bool (True would alias request id 1).""" + match params: + case {"requestId": str() | int() as request_id} if not isinstance(request_id, bool): + return request_id + case _: + return None @dataclass(slots=True) @@ -314,7 +326,22 @@ async def send_raw_request( if not self._running: raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run()") opts = opts or {} - request_id = self._allocate_id() + supplied_id = opts.get("request_id") + if supplied_id is not None: + request_id: RequestId = supplied_id + # The pending key gets the same coercion `_resolve_pending` applies + # to inbound response ids, so a supplied "7" still correlates + # whether the peer echoes "7" or 7. The wire id stays verbatim. + pending_key = coerce_request_id(request_id) + if pending_key in self._pending: + raise ValueError(f"request id {request_id!r} is already in flight") + else: + # Mint past any key a supplied id occupies: the collision error is + # reserved for the caller who actually chose the id. + request_id = self._allocate_id() + while request_id in self._pending: + request_id = self._allocate_id() + pending_key = request_id out_params = dict(params) if params is not None else {} out_meta = dict(out_params.get("_meta") or {}) on_progress = opts.get("on_progress") @@ -327,7 +354,7 @@ async def send_raw_request( # a WouldBlock later just means the waiter already has its one outcome. send, receive = anyio.create_memory_object_stream[dict[str, Any] | ErrorData](1) pending = _Pending(send=send, receive=receive, on_progress=on_progress) - self._pending[request_id] = pending + self._pending[pending_key] = pending plan = _plan_outbound(_related_request_id, opts) # Spec MUST: only previously-issued requests may be cancelled. A write @@ -398,7 +425,7 @@ async def send_raw_request( raise finally: # Remove the waiter on every path so a late response is dropped, not leaked. - self._pending.pop(request_id, None) + self._pending.pop(pending_key, None) send.close() receive.close() @@ -548,7 +575,7 @@ async def _dispatch_request( # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit # rejecting with INVALID_REQUEST. Key coerced so a stringified # `notifications/cancelled` id still correlates. - self._in_flight[_coerce_id(req.id)] = _InFlight(scope=scope, dctx=dctx) + self._in_flight[coerce_request_id(req.id)] = _InFlight(scope=scope, dctx=dctx) if req.method in self._inline_methods: # Spawn so `sender_ctx` applies, but park the read loop until the # handler returns - that's the inline ordering guarantee. @@ -579,22 +606,17 @@ def _dispatch_notification( layer owns) and still teed to `on_notify` afterwards. """ if msg.method == "notifications/cancelled": - match msg.params: - # bool subclasses int: the guards keep True from aliasing request id 1. - case {"requestId": str() | int() as rid} if ( - not isinstance(rid, bool) and (in_flight := self._in_flight.get(_coerce_id(rid))) is not None - ): - in_flight.dctx.cancel_requested.set() - if self._peer_cancel_mode == "interrupt": - in_flight.scope.cancel() - case _: - pass + rid = cancelled_request_id_from_params(msg.params) + if rid is not None and (in_flight := self._in_flight.get(coerce_request_id(rid))) is not None: + in_flight.dctx.cancel_requested.set() + if self._peer_cancel_mode == "interrupt": + in_flight.scope.cancel() elif msg.method == "notifications/progress": match msg.params: case {"progressToken": str() | int() as token, "progress": int() | float() as progress} if ( not isinstance(token, bool) and not isinstance(progress, bool) - and (pending := self._pending.get(_coerce_id(token))) is not None + and (pending := self._pending.get(coerce_request_id(token))) is not None and pending.on_progress is not None ): total = msg.params.get("total") @@ -620,7 +642,7 @@ def _dispatch_notification( self._spawn(_contained_notify(on_notify), dctx, msg.method, msg.params, sender_ctx=sender_ctx) def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None: - pending = self._pending.get(_coerce_id(request_id)) if request_id is not None else None + pending = self._pending.get(coerce_request_id(request_id)) if request_id is not None else None if pending is None: logger.debug("dropping response for unknown/late request id %r", request_id) return @@ -680,7 +702,7 @@ async def _handle_request( # since handler return, so a peer cancel can't interleave. # Identity guard: don't evict a duplicate id's newer entry. dctx.close() - key = _coerce_id(req.id) + 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 diff --git a/tests/client/test_session.py b/tests/client/test_session.py index f76991f65..2a53f67ce 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1330,43 +1330,43 @@ def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None: assert session.protocol_version is None -@pytest.mark.anyio -async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_leave_it_unset(): - """`send_request` passes `cancel_on_abandon=False` for `initialize` — the spec forbids - cancelling it — and leaves the option unset for every other method.""" +class _OptsRecordingDispatcher: + """Records `send_raw_request` opts and answers from a per-method script (default `{}`).""" - class RecordingDispatcher: - """Records `send_raw_request` opts and answers with canned results.""" + def __init__(self, answers: dict[str, dict[str, Any]] | None = None) -> None: + self.calls: list[tuple[str, CallOptions]] = [] + self._answers = answers or {} - def __init__(self) -> None: - self.calls: list[tuple[str, CallOptions]] = [] + async def run( + self, + on_request: OnRequest, + on_notify: OnNotify, + *, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ) -> None: + task_status.started() + await anyio.sleep_forever() - async def run( - self, - on_request: OnRequest, - on_notify: OnNotify, - *, - task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, - ) -> None: - task_status.started() - await anyio.sleep_forever() + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + self.calls.append((method, opts or {})) + return self._answers.get(method, {}) - async def send_raw_request( - self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None - ) -> dict[str, Any]: - self.calls.append((method, opts or {})) - if method == "initialize": - return InitializeResult( - protocol_version=LATEST_HANDSHAKE_VERSION, - capabilities=ServerCapabilities(), - server_info=Implementation(name="mock-server", version="0.1.0"), - ).model_dump(by_alias=True, mode="json", exclude_none=True) - return {} + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + pass - async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: - pass - dispatcher = RecordingDispatcher() +@pytest.mark.anyio +async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_leave_it_unset(): + """`send_request` passes `cancel_on_abandon=False` for `initialize` — the spec forbids + cancelling it — and leaves the option unset for every other method.""" + init_answer = InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True) + dispatcher = _OptsRecordingDispatcher({"initialize": init_answer}) with anyio.fail_after(5): async with ClientSession(dispatcher=dispatcher) as session: await session.initialize() @@ -1376,6 +1376,27 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call assert "cancel_on_abandon" not in opts_by_method["ping"] +@pytest.mark.anyio +async def test_modern_stamp_leaves_cancel_on_abandon_at_the_dispatcher_default(): + """Post-adopt modern requests leave `cancel_on_abandon` unset (the dispatcher default, + True): the courtesy frame is the abandon signal — the 2026 cancellation spelling on + stream transports, and the streamable-HTTP transport's cue to abort the request's own + POST. The negotiation methods still opt out on every path: `send_discover`'s explicit + opts, and the stamp's own carve-out for a `server/discover` sent through the generic + `send_request`.""" + dispatcher = _OptsRecordingDispatcher({"server/discover": _discover_result_dict()}) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + await session.send_ping() + await session.send_request(types.DiscoverRequest(params=types.RequestParams()), types.DiscoverResult) + assert [method for method, _ in dispatcher.calls] == ["server/discover", "ping", "server/discover"] + negotiation_opts, ping_opts, stamped_negotiation_opts = (opts for _, opts in dispatcher.calls) + assert negotiation_opts.get("cancel_on_abandon") is False + assert "cancel_on_abandon" not in ping_opts + assert stamped_negotiation_opts.get("cancel_on_abandon") is False + + def test_constructor_rejects_streams_and_dispatcher_together(): client_side, _server_side = create_direct_dispatcher_pair() s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 99ff6f03e..defda41f8 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -8,16 +8,37 @@ import base64 import json +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any import anyio import httpx import pytest from inline_snapshot import snapshot -from mcp_types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, +) +from mcp_types.version import LATEST_MODERN_VERSION +from starlette.types import Receive, Scope, Send from mcp.client.streamable_http import streamable_http_client -from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, encode_header_value -from mcp.shared.message import ClientMessageMetadata, SessionMessage +from mcp.server import Server +from mcp.server._streamable_http_modern import handle_modern_request +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent +from mcp.shared.dispatcher import CallOptions, DispatchContext +from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage +from mcp.shared.transport_context import TransportContext +from tests.interaction.transports import StreamingASGITransport +from tests.shared.test_dispatcher import Recorder, echo_handlers @pytest.mark.parametrize( @@ -154,3 +175,411 @@ def handler(request: httpx.Request) -> httpx.Response: assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25" assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25" + + +class _ParkedSSEStream(httpx.AsyncByteStream): + """An SSE response body that emits one comment line, then parks until closed. + + `opened` fires once the transport is iterating the body (the POST is truly in + flight); `closed` fires when httpx tears the body down — the observable proof + that an abort, not a response, ended the stream. + """ + + def __init__(self) -> None: + self.opened = anyio.Event() + self.closed = anyio.Event() + self._release = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + yield b": parked\n\n" + await self._release.wait() + + async def aclose(self) -> None: + self.closed.set() + self._release.set() + + +def _sse_or_ack_handler( + parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event +) -> Callable[[httpx.Request], httpx.Response]: + """Requests get the parked SSE body; notifications get 202 and set `frame_posted`.""" + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + posted.append(body) + if "id" in body: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + frame_posted.set() + return httpx.Response(202) + + return handler + + +@pytest.mark.anyio +async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> None: + """At 2026 an outbound `notifications/cancelled` never POSTs — closing the named + request's response stream IS the wire's cancellation signal — so the transport + aborts the in-flight POST and swallows the frame.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + posted.append(json.loads(request.content)) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (_read, write), + ): + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage( + JSONRPCNotification( + jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "listen-1"} + ) + ) + ) + await parked.closed.wait() + assert [body["method"] for body in posted] == ["subscriptions/listen"] + + +@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: + """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).""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + frame_posted = anyio.Event() + handler = _sse_or_ack_handler(parked, posted, frame_posted) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (_read, write), + ): + metadata = ( + ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: stamped_version}) + if stamped_version is not None + else None + ) + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}), + metadata=metadata, + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}) + ) + ) + await frame_posted.wait() + # 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"] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "params", + [ + pytest.param({"requestId": 999}, id="unknown-id"), + pytest.param({"requestId": True}, id="bool-must-not-alias-request-id-1"), + pytest.param({"requestId": "1"}, id="string-1-must-not-match-int-1"), + pytest.param({}, id="no-request-id"), + pytest.param(None, id="no-params"), + ], +) +async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: dict[str, Any] | None) -> None: + """At 2026 the frame is swallowed even when it aborts nothing — the wire defines no + client-to-server notifications, so a late cancel racing the response must not leak + a POST — and a mismatched id must not abort someone else's stream.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + posted.append(body) + if body.get("id") == 1: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="subscriptions/listen", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params=params)) + ) + # A follow-up request completing proves the loop moved past the swallowed frame. + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}))) + reply = await read.receive() + # Checked before teardown: exiting the transport cancels the parked POST. + assert not parked.closed.is_set() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse) + assert reply.message.id == 2 + assert [body["method"] for body in posted] == ["subscriptions/listen", "ping"] + + +@pytest.mark.anyio +async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> None: + """A cancel carrying `ServerMessageMetadata` (a handler abandoning its own + back-channel request) still names one of OUR outbound ids — every spec-legal + cancel names a request its sender issued — so at 2026 it aborts that POST and + stays off the wire like any other.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + frame_posted = anyio.Event() + handler = _sse_or_ack_handler(parked, posted, frame_posted) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (_read, write), + ): + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage( + message=JSONRPCNotification( + jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1} + ), + metadata=ServerMessageMetadata(related_request_id=99), + ) + ) + await parked.closed.wait() + assert [body["method"] for body in posted] == ["tools/call"] + assert not frame_posted.is_set() + + +@pytest.mark.anyio +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.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + frame_posted = anyio.Event() + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + posted.append(body) + if body.get("id") == 1: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + if "id" in body: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + frame_posted.set() + return httpx.Response(202) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}), + ) + ) + await parked.opened.wait() + # A modern-stamped request flips the cached negotiated version. + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse) + await write.send( + SessionMessage( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}) + ) + ) + await frame_posted.wait() + # 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"] + + +class _SignalingBus(InMemorySubscriptionBus): + """Signals subscribe/unsubscribe so a test observes the stream lifecycle through + the bus Protocol (the public seam) instead of polling handler internals.""" + + def __init__(self) -> None: + super().__init__() + self.subscribed = anyio.Event() + self.unsubscribed = anyio.Event() + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + unsubscribe = super().subscribe(listener) + self.subscribed.set() + + def unsubscribe_and_signal() -> None: + unsubscribe() + self.unsubscribed.set() + + return unsubscribe_and_signal + + +@pytest.mark.anyio +async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None: + """Over a real ASGI bridge: cancelling the caller of a parked `subscriptions/listen` + closes the POST's response stream — the server treats the disconnect as the cancel + and releases the subscription — and no `notifications/cancelled` crosses the wire.""" + bus = _SignalingBus() + server = Server("test", on_subscriptions_listen=ListenHandler(bus)) + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + async with server.lifespan(server) as lifespan_state: + await handle_modern_request(server, None, False, lifespan_state, scope, receive, send) + + posted_methods: list[str] = [] + + async def record_request(request: httpx.Request) -> None: + posted_methods.append(json.loads(request.content)["method"]) + + acked = anyio.Event() + + async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: + assert method == "notifications/subscriptions/acknowledged" + acked.set() + + on_request, _ = echo_handlers(Recorder()) + + with anyio.fail_after(15): + async with ( + httpx.AsyncClient( + transport=StreamingASGITransport(app), + base_url="http://testserver", + event_hooks={"request": [record_request]}, + ) as http, + streamable_http_client("http://testserver/mcp", http_client=http) as (read, write), + ): + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read, write) + async with anyio.create_task_group() as tg: # pragma: no branch + await tg.start(dispatcher.run, on_request, on_notify) + listen_scope = anyio.CancelScope() + + async def send_listen() -> None: + params: dict[str, Any] = { + "_meta": { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "test-client", "version": "0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + }, + "notifications": {"toolsListChanged": True}, + } + opts: CallOptions = { + "request_id": "listen-1", + "headers": { + MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, + MCP_METHOD_HEADER: "subscriptions/listen", + }, + } + with listen_scope: + await dispatcher.send_raw_request("subscriptions/listen", params, opts) + + tg.start_soon(send_listen) + await acked.wait() + assert bus.subscribed.is_set() + assert not bus.unsubscribed.is_set() + listen_scope.cancel() + await bus.unsubscribed.wait() + tg.cancel_scope.cancel() + assert posted_methods == ["subscriptions/listen"] + + +class _CompletingSSEStream(httpx.AsyncByteStream): + """An SSE body that delivers one JSON-RPC response, then parks in `aclose`. + + Holding `aclose` keeps the finished POST task alive past its response, so a + test can re-register the same request id underneath it before releasing. + """ + + def __init__(self, response_body: dict[str, Any]) -> None: + self._event = f"data: {json.dumps(response_body)}\n\n".encode() + self.release = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._event + + async def aclose(self) -> None: + await self.release.wait() + + +@pytest.mark.anyio +async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration() -> None: + """Request ids are reusable once resolved; a finished POST task unwinding late + must not pop the successor's registration, or a cancel for the reused id would + find nothing to abort and the live POST would leak past the cancellation.""" + completing = _CompletingSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {}}) + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + streams = [completing, parked] + + def handler(request: httpx.Request) -> httpx.Response: + posted.append(json.loads(request.content)) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0)) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}), + metadata=modern, + ) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse) + # The first task is now parked in `aclose`; reuse its id underneath it. + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="subscriptions/listen", params={}), + metadata=modern, + ) + ) + await parked.opened.wait() + completing.release.set() + await anyio.wait_all_tasks_blocked() + # The successor's registration survived: a cancel still aborts it. + await write.send( + SessionMessage( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "dup-1"}) + ) + ) + await parked.closed.wait() + assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"] diff --git a/tests/shared/test_dispatcher.py b/tests/shared/test_dispatcher.py index 1f8208337..03ef27c8d 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -19,6 +19,7 @@ INVALID_REQUEST, REQUEST_TIMEOUT, ErrorData, + RequestId, Tool, ) @@ -396,6 +397,118 @@ async def test_direct_close_makes_run_return(): server.close() +@pytest.mark.anyio +async def test_send_raw_request_honors_caller_supplied_request_id_verbatim_typed(pair_factory: PairFactory): + """A caller-supplied `CallOptions["request_id"]` reaches the peer's context verbatim — + "7" stays a string, never the integer 7 — and the next call without one still mints + a dispatcher id as before.""" + async with running_pair(pair_factory) as (client, _server, _crec, srec): + with anyio.fail_after(5): + await client.send_raw_request("first", None, {"request_id": "7"}) + await client.send_raw_request("second", None) + supplied, minted = (ctx.request_id for ctx in srec.contexts) + assert supplied == "7" + assert type(supplied) is str + assert type(minted) is int + + +@pytest.mark.anyio +async def test_send_raw_request_with_in_flight_request_id_raises_and_frees_id_on_completion( + pair_factory: PairFactory, +): + """Reusing an id while it is in flight is a loud `ValueError` — silent reuse would + corrupt response correlation. Once the first request completes, the id is free + again: the reservation is in-flight-scoped, not permanent.""" + entered = anyio.Event() + release = anyio.Event() + + async def parked( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + entered.set() + await release.wait() + return {"served": method} + + async with running_pair(pair_factory, server_on_request=parked) as (client, *_): + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def first() -> None: + await client.send_raw_request("slow", None, {"request_id": "listen-1"}) + + tg.start_soon(first) + await entered.wait() + with pytest.raises(ValueError, match="already in flight"): + await client.send_raw_request("duplicate", None, {"request_id": "listen-1"}) + release.set() + result = await client.send_raw_request("again", None, {"request_id": "listen-1"}) + assert result == {"served": "again"} + + +@pytest.mark.anyio +async def test_minted_ids_skip_a_caller_supplied_id_still_in_flight(pair_factory: PairFactory): + """The dispatcher mints PAST a key a supplied id occupies — the collision error + is reserved for the caller who chose the id, never an innocent minted request.""" + entered = anyio.Event() + release = anyio.Event() + seen_ids: list[RequestId | None] = [] + + async def maybe_park( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + seen_ids.append(ctx.request_id) + if method == "park": + entered.set() + await release.wait() + return {} + + async with running_pair(pair_factory, server_on_request=maybe_park) as (client, *_): + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def parked() -> None: + await client.send_raw_request("park", None, {"request_id": "3"}) + + tg.start_soon(parked) + await entered.wait() + # The counter mints 1 and 2, then skips the occupied 3 to 4. + for _ in range(3): + await client.send_raw_request("plain", None) + release.set() + assert [request_id for request_id in seen_ids if request_id != "3"] == [1, 2, 4] + + +@pytest.mark.anyio +async def test_supplied_numeric_string_id_collides_with_its_int_twin(pair_factory: PairFactory): + """ "7" and 7 are one id in the collision domain on BOTH dispatchers, so the + in-memory pair raises exactly where the wire dispatcher (whose pending keys + are coerced for response correlation) would.""" + entered = anyio.Event() + release = anyio.Event() + + async def parked( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + entered.set() + await release.wait() + return {} + + async with running_pair(pair_factory, server_on_request=parked) as (client, *_): + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def first() -> None: + await client.send_raw_request("slow", None, {"request_id": 7}) + + tg.start_soon(first) + await entered.wait() + with pytest.raises(ValueError, match="already in flight"): + await client.send_raw_request("duplicate", None, {"request_id": "7"}) + release.set() + # Completion frees the id for either spelling. + assert await client.send_raw_request("again", None, {"request_id": "7"}) == {} + + if TYPE_CHECKING: _d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) _o: Outbound = _d diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 82d16bc4b..e91fc2de2 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -34,11 +34,10 @@ 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 +from mcp.shared.dispatcher import CallOptions, DispatchContext, coerce_request_id from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage] JSONRPCDispatcher, - _coerce_id, _OutboundPlan, _Pending, _plan_outbound, @@ -1821,7 +1820,7 @@ async def respond_stringly() -> None: @pytest.mark.anyio async def test_error_response_with_string_id_correlates_to_int_keyed_pending_request(): - """A JSONRPCError echoing the request ID as a JSON string still resolves the waiter (same `_coerce_id` path).""" + """A JSONRPCError echoing the request ID as a JSON string still resolves the waiter (`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) client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send) @@ -1900,10 +1899,10 @@ async def on_progress(progress: float, total: float | None, message: str | None) assert seen == [0.5] -def test_coerce_id_passes_through_non_numeric_string_and_int(): - assert _coerce_id("7") == 7 - assert _coerce_id("not-an-int") == "not-an-int" - assert _coerce_id(42) == 42 +def test_coerce_request_id_passes_through_non_numeric_string_and_int(): + assert coerce_request_id("7") == 7 + assert coerce_request_id("not-an-int") == "not-an-int" + assert coerce_request_id(42) == 42 @pytest.mark.anyio @@ -2154,7 +2153,7 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ids=["string-cancel-for-int-request", "int-cancel-for-string-request"], ) 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_id` path).""" + """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) @@ -2381,3 +2380,38 @@ async def call() -> None: assert observed[0][0] == "notifications/cancelled" assert observed[0][1]["requestId"] == request_id assert observed[0][1]["reason"] == "user clicked stop" + + +@pytest.mark.anyio +async def test_send_raw_request_with_caller_supplied_string_id_is_verbatim_on_the_wire(): + """A supplied "7" goes on the wire as the string "7", and the response still + correlates when the peer echoes it back as the integer 7 — the pending key gets + the same coercion `_resolve_pending` applies to inbound ids.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send) + on_request, on_notify = echo_handlers(Recorder()) + result_box: list[dict[str, Any]] = [] + done = anyio.Event() + try: + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def call() -> None: + result_box.append(await client.send_raw_request("tools/list", None, {"request_id": "7"})) + done.set() + + await tg.start(client.run, on_request, on_notify) + tg.start_soon(call) + wire = await c2s_recv.receive() + assert isinstance(wire, SessionMessage) + assert isinstance(wire.message, JSONRPCRequest) + assert wire.message.id == "7" + assert type(wire.message.id) is str + await s2c_send.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=7, result={"ok": True}))) + await done.wait() + tg.cancel_scope.cancel() + finally: + for stream in (c2s_send, c2s_recv, s2c_send, s2c_recv): + stream.close() + assert result_box == [{"ok": True}] From eb6a578957aa7856be18dc7e5ab8f7be29ee5e16 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:51:39 +0000 Subject: [PATCH 2/8] Add the client-side subscriptions/listen driver client.listen() opens one subscription as an async context manager: entering sends the request and waits for the server's acknowledgment (the honored filter subset is on the handle before the first event), iteration yields the same four typed events the server publishes, a graceful server close simply ends the loop, and an abrupt drop raises SubscriptionLost. Exiting the context ends the subscription with the transport's own cancellation spelling (aborting the request's stream over streamable HTTP, notifications/cancelled on stream transports). There is no replay and no automatic re-listen. Pending events deduplicate - every kind is a level trigger - so the backlog is bounded by the filter's width by construction. The event vocabulary moves to mcp/shared/subscriptions.py (re-exported from the server module) so both sides speak the same types. The session demultiplexes stream frames by the _meta subscription id: acks in the driver's id namespace are consumed (raw escape-hatch listens keep observing theirs through message_handler), change events are delivered after the message_handler tee so cache eviction always completes before an event-triggered refetch can run, and session teardown settles every open route so a watcher task in a sibling task group cannot hang when the client closes. A per-request SSE stream that drops without ever carrying an event id now resolves its request with CONNECTION_CLOSED instead of leaving it pending for the session's lifetime. The legacy subscribe_resource/unsubscribe_resource verbs are deprecated on both Client and ClientSession with a pointer at the replacement. --- docs/handlers/subscriptions.md | 32 ++ docs/migration.md | 17 + docs_src/subscriptions/tutorial003.py | 14 + examples/stories/subscriptions/client.py | 80 +-- src/mcp/client/client.py | 60 +- src/mcp/client/session.py | 89 ++- src/mcp/client/streamable_http.py | 11 +- src/mcp/client/subscriptions.py | 284 ++++++++++ src/mcp/server/mcpserver/context.py | 6 +- src/mcp/server/subscriptions.py | 78 +-- src/mcp/shared/dispatcher.py | 12 + src/mcp/shared/jsonrpc_dispatcher.py | 9 +- src/mcp/shared/subscriptions.py | 99 ++++ tests/client/test_client.py | 8 +- tests/client/test_session.py | 38 ++ tests/client/test_streamable_http.py | 42 ++ tests/client/test_subscriptions.py | 536 ++++++++++++++++++ tests/docs_src/test_client.py | 6 +- tests/docs_src/test_subscriptions.py | 21 +- tests/interaction/_requirements.py | 44 ++ tests/interaction/lowlevel/test_resources.py | 9 +- .../lowlevel/test_subscriptions.py | 64 +++ .../mcpserver/test_subscriptions.py | 65 +++ 23 files changed, 1477 insertions(+), 147 deletions(-) create mode 100644 docs_src/subscriptions/tutorial003.py create mode 100644 src/mcp/client/subscriptions.py create mode 100644 src/mcp/shared/subscriptions.py create mode 100644 tests/client/test_subscriptions.py create mode 100644 tests/interaction/lowlevel/test_subscriptions.py create mode 100644 tests/interaction/mcpserver/test_subscriptions.py diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 6ff85dd86..852814b7f 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -85,6 +85,37 @@ Down on the low-level `Server` there is no pre-wired anything — and the same p * `ListenHandler(bus)` is the same handler `MCPServer` registers; `on_subscriptions_listen=` is an ordinary handler slot. Don't want the SDK's semantics? Write your own handler for the slot — the spec obligations come with it. * `ListenHandler.close()` gracefully ends every open stream: each one receives the listen request's result as its final frame, the spec's signal that the server ended the subscription deliberately — a clean end, as opposed to the abrupt drop a client may treat as a cue to reconnect. Without it, streams end when the client disconnects. +## The client side + +Consuming a subscription is one context manager: + +```python title="client.py" hl_lines="9 10" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +* `client.listen(...)` takes the filter as keyword arguments — they mirror the wire `SubscriptionFilter` field for field. Entering sends the request and returns once the server's acknowledgment arrives, so `sub.honored` (the subset the server agreed to deliver) is always there before the first event. +* Iteration yields the same four typed events the server publishes: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`. An event is a cue to refetch — it carries no payload beyond identity, and duplicates pending consumption collapse into one. +* Leaving the block ends the subscription, with the transport's own spelling: over streamable HTTP the request's response stream is closed (that is the 2026 cancellation signal), on stream transports `notifications/cancelled` is sent. +* The stream's two endings are control flow. The server closing gracefully simply ends the `async for`; an abrupt drop raises `SubscriptionLost`. There is no replay and no automatic re-listen — a client that reconnects refetches what it depends on: + +```python +async def watch(client: Client, uri: str) -> None: + while True: + try: + async with client.listen(resource_subscriptions=[uri]) as sub: + await client.read_resource(uri) # refetch: no replay across streams + async for _event in sub: + await client.read_resource(uri) + except SubscriptionLost: + continue # transport dropped — re-listen + else: + break # the server ended it deliberately +``` + +* Checking the acknowledgment (the spec's client SHOULD) is reading `sub.honored` — for example, `if not sub.honored.prompts_list_changed:` the server has no prompts to watch. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id. +* Tool calls and other requests run freely beside an open stream — from the same task between events, or from sibling tasks sharing the client. A watcher task that refetches inside its event loop is the intended pattern, not a re-entrancy hazard. +* `listen()` requires a 2026-07-28 connection and raises `ListenNotSupportedError` on older ones, steering to the deprecated `subscribe_resource` and `message_handler` spelling those wires use. + ## Recap * A client opts in with one `subscriptions/listen` request; the response is the stream. There is nothing to configure server-side — serving it is built in. @@ -92,3 +123,4 @@ Down on the low-level `Server` there is no pre-wired anything — and the same p * Streams receive only what their filter requested; URIs match exactly; nothing is replayed. * Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`. * The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument. +* Consuming is `async with client.listen(...)` and `async for event in sub` — typed events, honored filter on the handle, clean end vs `SubscriptionLost`. diff --git a/docs/migration.md b/docs/migration.md index 9ff5a054c..b0682a573 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1536,6 +1536,23 @@ The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663]( ## Deprecations +### Client resource-subscription methods deprecated (SEP-2575) + +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`: + +- `Client.subscribe_resource()` / `Client.unsubscribe_resource()` +- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()` + +They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver: + +```python +async with client.listen(resource_subscriptions=["note://todo"]) as sub: + async for event in sub: # ResourceUpdated(uri="note://todo") + ... +``` + +See the [Subscriptions](advanced/subscriptions.md) page for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). + ### Roots, Sampling, and Logging methods deprecated (SEP-2577) [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier. diff --git a/docs_src/subscriptions/tutorial003.py b/docs_src/subscriptions/tutorial003.py new file mode 100644 index 000000000..109f838b9 --- /dev/null +++ b/docs_src/subscriptions/tutorial003.py @@ -0,0 +1,14 @@ +from mcp import Client +from mcp.client.subscriptions import ResourceUpdated + +from .tutorial001 import mcp + + +async def watch_todo() -> str: + """Wait for the todo note to change once, then stop listening.""" + async with Client(mcp) as client: + async with client.listen(resource_subscriptions=["note://todo"]) as sub: + async for event in sub: + assert isinstance(event, ResourceUpdated) + return f"changed: {event.uri}" + return "the server closed the stream before any change" diff --git a/examples/stories/subscriptions/client.py b/examples/stories/subscriptions/client.py index 379d69bc6..d2053aaf7 100644 --- a/examples/stories/subscriptions/client.py +++ b/examples/stories/subscriptions/client.py @@ -4,88 +4,34 @@ import mcp_types as types from mcp.client import Client +from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged from stories._harness import Target, run_client -SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId" - async def main(target: Target, *, mode: str = "auto") -> None: - # Stream frames arrive as ordinary server notifications; `message_handler` - # is constructor-only on `Client`, so the list it fills exists first. - received: list[types.ServerNotification] = [] - arrival = anyio.Event() - - async def on_message(message: object) -> None: - nonlocal arrival - if isinstance( - message, - types.SubscriptionsAcknowledgedNotification - | types.ResourceUpdatedNotification - | types.ToolListChangedNotification, - ): - received.append(message) - arrival.set() - arrival = anyio.Event() - - async def wait_for(count: int) -> None: - with anyio.fail_after(10): - while len(received) < count: - await arrival.wait() - - async with Client(target, mode=mode, message_handler=on_message) as client: + async with Client(target, mode=mode) as client: before = await client.list_tools() assert "search" not in {tool.name for tool in before.tools} - async with anyio.create_task_group() as tg: - # There is no client-side listen API yet, so the story drops to the - # `client.session` escape hatch. The request parks for the stream's - # lifetime, so it runs as a task; cancelling it releases the local - # awaiting scope. In-memory that also ends the server's stream; over - # HTTP today nothing aborts the POST, so the server-side stream ends - # when the connection closes (the `Client` exit right below). - async def listen() -> None: - request = types.SubscriptionsListenRequest( - params=types.SubscriptionsListenRequestParams( - notifications=types.SubscriptionFilter( - tools_list_changed=True, resource_subscriptions=["note://todo"] - ) - ) - ) - await client.session.send_request(request, types.SubscriptionsListenResult) - - tg.start_soon(listen) - - # ── the ack is the first frame: it echoes the honored filter, tagged ── - await wait_for(1) - ack = received[0] - assert isinstance(ack, types.SubscriptionsAcknowledgedNotification), ack - assert ack.params.notifications.tools_list_changed is True - assert ack.params.notifications.resource_subscriptions == ["note://todo"] - assert ack.params.meta is not None and SUBSCRIPTION_ID in ack.params.meta + async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub: + # ── entering waited for the ack: the honored filter is already in hand ── + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] # ── exact-URI filtering: an unsubscribed note edit stays silent ── await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) - # ── the subscribed URI delivers, carrying the same subscription id ── + # ── the subscribed URI delivers ── await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) - await wait_for(2) - updated = received[1] - assert isinstance(updated, types.ResourceUpdatedNotification), updated - assert updated.params.uri == "note://todo" - assert updated.params.meta is not None - assert updated.params.meta[SUBSCRIPTION_ID] == ack.params.meta[SUBSCRIPTION_ID] - assert len(received) == 2, "the journal edit must not have been delivered" + with anyio.fail_after(10): + event = await anext(sub) + assert event == ResourceUpdated(uri="note://todo"), "the journal edit must not have been delivered" # ── a runtime tool registration announces itself ── await client.call_tool("enable_search", {}) - await wait_for(3) - assert isinstance(received[2], types.ToolListChangedNotification), received[2] - - # The client is done listening: cancel the parked request and let - # the connection teardown below end the stream server-side. - tg.cancel_scope.cancel() + with anyio.fail_after(10): + assert await anext(sub) == ToolsListChanged() - # list_changed told us to re-fetch - the new tool is callable, and the - # session outlives the closed stream. + # ── leaving the block closed the stream; the session lives on ── tools = await client.list_tools() assert "search" in {tool.name for tool in tools.tools} result = await client.call_tool("search", {"query": "water"}) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d581fe6a5..cd54ee0f5 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -6,7 +6,7 @@ import logging import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence -from contextlib import AsyncExitStack +from contextlib import AbstractAsyncContextManager, AsyncExitStack from dataclasses import KW_ONLY, dataclass, field from typing import Any, Literal, TypeVar, cast @@ -58,6 +58,8 @@ SamplingFnT, ) from mcp.client.streamable_http import streamable_http_client +from mcp.client.subscriptions import Subscription +from mcp.client.subscriptions import listen as _listen from mcp.server import Server from mcp.server.mcpserver import MCPServer from mcp.server.runner import modern_on_request @@ -662,13 +664,61 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST). return await self._drive_input_required(first, retry) + def listen( + self, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), + ) -> AbstractAsyncContextManager[Subscription]: + """Open a `subscriptions/listen` stream of typed change events (2026-07-28 only). + + The keyword arguments mirror the wire `SubscriptionFilter` field for + field; `resource_subscriptions` names exact resource URIs to watch. + Entering waits for the server's acknowledgment - the subset it agreed + to deliver is `sub.honored` - then the handle yields events: + + async with client.listen(tools_list_changed=True) as sub: + async for event in sub: + tools = await client.list_tools() # refetch on change + + A graceful server close ends the loop; an abrupt drop raises + `SubscriptionLost` (re-listen and refetch - there is no replay). + Exiting the context ends the subscription. Multiple subscriptions may + be open concurrently. + + Raises: + ListenNotSupportedError: The negotiated protocol version predates + 2026-07-28 (use `subscribe_resource` and `message_handler` there). + MCPError: The server rejected the request, or the connection + failed before the acknowledgment arrived. + SubscriptionLost: The stream ended before it was acknowledged. + TimeoutError: The session's read timeout elapsed before the acknowledgment. + """ + return _listen( + self.session, + tools_list_changed=tools_list_changed, + prompts_list_changed=prompts_list_changed, + resources_list_changed=resources_list_changed, + resource_subscriptions=resource_subscriptions, + ) + + @deprecated( + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: - """Subscribe to resource updates.""" - return await self.session.subscribe_resource(uri, meta=meta) + """Subscribe to resource updates (2025-era servers only).""" + return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] + @deprecated( + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: - """Unsubscribe from resource updates.""" - return await self.session.unsubscribe_resource(uri, meta=meta) + """Unsubscribe from resource updates (2025-era servers only).""" + return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] async def call_tool( self, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 5c09304e4..4e49988e6 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -16,6 +16,7 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, INTERNAL_ERROR, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, @@ -35,8 +36,9 @@ from mcp.client._transport import ReadStream, WriteStream from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult +from mcp.client.subscriptions import ListenRoute from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT +from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT, as_request_id from mcp.shared.exceptions import MCPDeprecationWarning, MCPError from mcp.shared.inbound import ( MCP_METHOD_HEADER, @@ -51,6 +53,7 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_notification from mcp.shared.transport_context import TransportContext DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -360,6 +363,13 @@ def __init__( self._negotiated_version: str | None = None self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp self._task_group: anyio.abc.TaskGroup | None = None + # subscriptions/listen demux routes, keyed by the listen request's id + # (verbatim-typed: a plain dict already keeps 1 and "1" distinct). + self._listen_routes: dict[RequestId, ListenRoute] = {} + # Every id the driver ever minted on this session: a late ack for a + # closed driver listen must be dropped, while raw escape-hatch listens + # (never in this set) keep receiving their acks via message_handler. + self._driver_listen_ids: set[RequestId] = set() if dispatcher is not None: if read_stream is not None or write_stream is not None: raise ValueError("pass read_stream/write_stream or dispatcher, not both") @@ -422,6 +432,7 @@ async def __aexit__( result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb) finally: self._close_binding_queues() + self._settle_listen_routes_closed() await resync_tracer() return result @@ -859,15 +870,23 @@ async def read_resource( raise _input_required_unexpected("read_resource") return result + @deprecated( + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: - """Send a resources/subscribe request.""" + """Send a resources/subscribe request (2025-era servers only).""" return await self.send_request( types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) + @deprecated( + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: - """Send a resources/unsubscribe request.""" + """Send a resources/unsubscribe request (2025-era servers only).""" return await self.send_request( types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, @@ -1225,6 +1244,44 @@ async def dispatch_input_request( case types.ListRootsRequest(): # pragma: no branch return await self._list_roots_callback(ctx) + def _register_listen_route(self, request_id: RequestId) -> ListenRoute: + """Create the demux route for a listen request id; the caller registers BEFORE sending.""" + route = ListenRoute() + self._listen_routes[request_id] = route + self._driver_listen_ids.add(request_id) + return route + + def _unregister_listen_route(self, request_id: RequestId) -> None: + """Drop a listen route; the handle owns membership, so a missing key is a no-op.""" + self._listen_routes.pop(request_id, None) + + def _settle_listen_routes_closed(self) -> None: + """End every open subscription as lost: the session is gone. + + Without this, a consumer iterating in a sibling task would park forever - + the driver task dies by cancellation and can never settle its route. + """ + closed = MCPError(code=CONNECTION_CLOSED, message="Connection closed") + for route in self._listen_routes.values(): + route.settle("lost", error=closed) + self._listen_routes.clear() + + def _listen_subscription_id(self, notification: types.ServerNotification) -> RequestId | None: + """The frame's `_meta` subscription id, shape-checked. + + The `as_request_id` guard is not a tripwire: on pre-2026 wires the meta + key is untyped, so a non-id (even unhashable) value is constructible + and would fail the dict lookup; 2026 surface validation already + rejects those shapes. + """ + meta = notification.params.meta if notification.params is not None else None + return as_request_id(meta.get(SUBSCRIPTION_ID_META_KEY)) if meta is not None else None + + def _listen_route_for(self, notification: types.ServerNotification) -> ListenRoute | None: + """The route a listen-stream frame belongs to, by its `_meta` subscription id.""" + subscription_id = self._listen_subscription_id(notification) + return self._listen_routes.get(subscription_id) if subscription_id is not None else None + async def _on_notify( self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> None: @@ -1259,8 +1316,24 @@ async def _on_notify( logger.warning("Failed to validate notification: %s", method, exc_info=True) return if isinstance(notification, types.CancelledNotification): - # The dispatcher already applied the cancellation; not surfaced to message_handler. + # A server-sent cancel naming one of our listen requests is that + # stream's teardown signal (the stream-transport spelling); any + # other cancellation was already applied by the dispatcher. + # Neither is surfaced to message_handler. + cancelled_id = notification.params.request_id + if cancelled_id is not None and (listen_route := self._listen_routes.get(cancelled_id)) is not None: + listen_route.settle("lost") return + if isinstance(notification, types.SubscriptionsAcknowledgedNotification): + subscription_id = self._listen_subscription_id(notification) + if subscription_id is not None and subscription_id in self._driver_listen_ids: + # Driver state, never surfaced: a late ack for an already-closed + # driver listen is dropped rather than leaking to message_handler. + # Acks outside the driver's id namespace fall through - a raw + # escape-hatch listen observes its ack there. + if (listen_route := self._listen_routes.get(subscription_id)) is not None: + listen_route.set_acked(notification.params.notifications) + return try: if isinstance(notification, types.LoggingMessageNotification): await self._logging_callback(notification.params) @@ -1271,6 +1344,14 @@ async def _on_notify( # would otherwise fail the peer's send. A raising logging_callback # skips the message_handler tee for that notification (v1 parity). logger.exception("notification callback for %r raised", method) + # Deliver AFTER the tee: the caching layer's eviction wrapper runs in + # message_handler, and a consumer woken first could refetch into the + # stale entry - with deduplicated level-trigger events there would be + # no second wake to correct it. + if (listen_route := self._listen_route_for(notification)) is not None: + event = event_from_notification(notification) + if event is not None: + listen_route.deliver(event) async def _on_stream_exception(self, exc: Exception) -> None: """Deliver a transport-level fault to message_handler via a spawned task. diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 09e5048cc..ee6fc06cf 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -13,6 +13,7 @@ from anyio.abc import TaskGroup from httpx_sse import EventSource, ServerSentEvent, aconnect_sse from mcp_types import ( + CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, @@ -438,9 +439,17 @@ async def _handle_sse_response( logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover # Stream ended without response - reconnect if we received an event with ID - if last_event_id is not None: # pragma: no branch + if last_event_id is not None: logger.info("SSE stream disconnected, reconnecting...") await self._handle_reconnection(ctx, last_event_id, retry_interval_ms) + else: + # Not resumable: the response can never arrive. Resolve the waiter + # instead of leaving the request pending for the session's lifetime + # (a listen stream's consumer would otherwise hang instead of + # learning the subscription is lost). + error_data = ErrorData(code=CONNECTION_CLOSED, message="SSE stream ended without a response") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data)) + await ctx.read_stream_writer.send(error_msg) async def _handle_reconnection( self, diff --git a/src/mcp/client/subscriptions.py b/src/mcp/client/subscriptions.py new file mode 100644 index 000000000..929022b14 --- /dev/null +++ b/src/mcp/client/subscriptions.py @@ -0,0 +1,284 @@ +"""Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575). + +On the 2026 wire a client opts in to server change notifications by sending +one `subscriptions/listen` request whose response IS the stream. This module +turns that into an async context manager: + + async with client.listen(tools_list_changed=True) as sub: + async for event in sub: + ... # ToolsListChanged() - go refetch + +Entering waits for the server's acknowledgment, so `sub.honored` (the subset +of the requested filter the server agreed to deliver) is always populated. +Iteration yields the same typed events the server publishes. The stream's two +endings are control flow: a graceful server close simply ends the loop, an +abrupt drop raises `SubscriptionLost`. Exiting the context ends the +subscription with the transport's own cancellation spelling (aborting the +request's stream over streamable HTTP, `notifications/cancelled` on stream +transports). There is no replay and no automatic re-listen: a client that +re-opens a subscription refetches what it depends on. + +`listen(session, ...)` is the composable helper for callers holding a bare +`ClientSession`; `Client.listen(...)` is the high-level spelling. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager +from itertools import count +from typing import TYPE_CHECKING, Literal + +import anyio +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.shared.dispatcher import CallOptions +from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, +) + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +__all__ = [ + "ListenNotSupportedError", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "Subscription", + "SubscriptionLost", + "ToolsListChanged", + "listen", +] + +_listen_ids = count(1) +"""Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints.""" + +_SubscriptionEnd = Literal["graceful", "lost", "local"] + + +class ListenNotSupportedError(RuntimeError): + """`subscriptions/listen` requires a 2026-07-28 connection. + + On earlier protocol versions, subscribe with `subscribe_resource()` and + receive change notifications through the session's `message_handler`. + """ + + def __init__(self, negotiated_version: str | None) -> None: + self.negotiated_version = negotiated_version + super().__init__( + f"subscriptions/listen is not available at protocol version {negotiated_version!r}; it requires " + "2026-07-28. On earlier versions use subscribe_resource() and the change notifications delivered " + "through message_handler." + ) + + +class SubscriptionLost(RuntimeError): + """The subscription's stream ended without the server's graceful close. + + The transport dropped, or the server tore the stream down abruptly. There + is no replay: re-open with `listen()` and refetch what you depend on. + """ + + +class ListenRoute: + """Demux state for one listen stream, owned by the session's notification path. + + Package-internal (deliberately not in `__all__`): `ClientSession` + constructs and feeds it; `Subscription` consumes it. + + Everything here is synchronous on the event loop - the notification path + must never block on a slow consumer - and there is exactly one consumer + (the `Subscription`). Pending events deduplicate: every event kind is a + level trigger, so the backlog is bounded by the filter's width. + """ + + def __init__(self) -> None: + self.honored: types.SubscriptionFilter | None = None + self.acked = anyio.Event() + self.error: MCPError | None = None + self.end: _SubscriptionEnd | None = None + self._pending: dict[ServerEvent, None] = {} + self._wake = anyio.Event() + + def set_acked(self, honored: types.SubscriptionFilter) -> None: + """Record the acknowledged filter; the first ack wins, later ones are no-ops.""" + if not self.acked.is_set(): + self.honored = honored + self.acked.set() + + def deliver(self, event: ServerEvent) -> None: + """Queue `event` for the consumer; a duplicate of a pending event is dropped.""" + if self.end is None and event not in self._pending: + self._pending[event] = None + self._wake.set() + + def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: + """Record the stream's end; the first reason wins. + + Also wakes the ack waiter so a pre-ack failure surfaces immediately. + """ + if self.end is None: + self.end = end + self.error = error + self.acked.set() + self._wake.set() + + async def next_event(self) -> ServerEvent | _SubscriptionEnd: + """Return the next pending event, or the stream's end once drained. + + A "local" end short-circuits the backlog: the consumer left the + context, so buffered events must not read as live deliveries. The + other endings drain first - a graceful close never swallows events + that preceded it. + """ + while True: + # Snapshot the wake event BEFORE checking state: a deliver landing + # after the checks sets this same object, so the wait cannot miss it. + wake = self._wake + if self.end == "local": + return self.end + if self._pending: + event = next(iter(self._pending)) + del self._pending[event] + return event + if self.end is not None: + return self.end + await wake.wait() + self._wake = anyio.Event() + + +class Subscription: + """One open `subscriptions/listen` stream: an async iterator of typed events. + + Produced by `listen()` / `Client.listen()`, not constructed directly. + Buffered events are served before the stream's end is reported, so a + graceful close never swallows deliveries that preceded it. + """ + + def __init__(self, route: ListenRoute, subscription_id: types.RequestId, honored: types.SubscriptionFilter): + self._route = route + self.subscription_id = subscription_id + """The listen request's JSON-RPC id - the value stamped into every frame's `_meta`.""" + self.honored = honored + """The subset of the requested filter the server agreed to deliver (the acknowledgment).""" + + def __aiter__(self) -> Subscription: + return self + + async def __anext__(self) -> ServerEvent: + """Yield the next change event; the loop ends when the stream does. + + Raises: + SubscriptionLost: The stream dropped without the server's graceful close. + """ + outcome = await self._route.next_event() + if isinstance(outcome, str): + if outcome == "lost": + raise SubscriptionLost( + f"subscription {self.subscription_id!r} ended without the server's graceful close;" + " re-listen and refetch" + ) from self._route.error + # "graceful" (the server's deliberate close) and "local" (the + # consumer already left the context) both end iteration cleanly. + raise StopAsyncIteration + return outcome + + +@asynccontextmanager +async def listen( + session: ClientSession, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), +) -> AsyncIterator[Subscription]: + """Open one `subscriptions/listen` stream on `session` (2026-07-28 only). + + The keyword arguments mirror the wire `SubscriptionFilter` field for + field; `resource_subscriptions` names exact resource URIs to watch for + `ResourceUpdated` events. Entering sends the request and returns once the + server's acknowledgment arrives (bounded by the session's read timeout, + when one is set). Exiting ends the subscription. Multiple subscriptions + may be open concurrently; each demultiplexes by its own subscription id. + + Raises: + ListenNotSupportedError: The negotiated protocol version predates 2026-07-28. + MCPError: The server rejected the request, or the connection failed + before the acknowledgment arrived. + SubscriptionLost: The stream ended before it was acknowledged. + TimeoutError: The session's read timeout elapsed before the acknowledgment. + """ + if session.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise ListenNotSupportedError(session.protocol_version) + if isinstance(resource_subscriptions, str): + raise TypeError("resource_subscriptions takes a sequence of URIs, not a bare string") + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=types.SubscriptionFilter( + tools_list_changed=tools_list_changed or None, + prompts_list_changed=prompts_list_changed or None, + resources_list_changed=resources_list_changed or None, + resource_subscriptions=list(resource_subscriptions) or None, + ) + ) + ) + task_group = session._task_group # pyright: ignore[reportPrivateUsage] + if task_group is None: + raise RuntimeError("listen() requires an entered session") + request_id: types.RequestId = f"listen-{next(_listen_ids)}" + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": request_id} + session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + driver_scope = anyio.CancelScope() + + async def drive() -> None: + # The listen request deliberately carries no result timeout: its + # response arrives when the stream ends, however long that takes. + with driver_scope: + try: + await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + except MCPError as error: + route.settle("lost", error=error) + return + except ValueError as error: + # A caller-supplied raw request id collided with our minted + # listen id: fail this subscription, not the whole session. + route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error))) + return + # The empty result is the spec's graceful close. Tolerant by design: + # receiving it IS the signal, whatever its body. A result with no + # prior ack opens the subscription already closed. + route.set_acked(types.SubscriptionFilter()) + route.settle("graceful") + + # Register the demux route before the request is written so the ack can + # never race it; from here the finally owns cleanup, so a failing spawn + # cannot leak the registration. + route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + try: + task_group.start_soon(drive) + with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage] + await route.acked.wait() + if route.honored is None: + # No ack means no subscription: raise, don't degrade. (A graceful + # result with no ack acked an empty filter in drive(), so honored + # is None here only on the failure paths.) + if route.error is not None: + raise route.error + raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged") + yield Subscription(route, request_id, route.honored) + finally: + route.settle("local") + driver_scope.cancel() + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 28d06761d..2b7fdf35e 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -16,14 +16,14 @@ elicit_with_validation, ) from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.subscriptions import ( +from mcp.server.subscriptions import SubscriptionBus +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.subscriptions import ( PromptsListChanged, ResourcesListChanged, ResourceUpdated, - SubscriptionBus, ToolsListChanged, ) -from mcp.shared.exceptions import MCPDeprecationWarning if TYPE_CHECKING: from mcp.server.mcpserver.server import MCPServer diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py index d071cfdbf..8e0cb551f 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -13,6 +13,10 @@ `MCPServer` registers one automatically; lowlevel `Server` users pass an instance as `on_subscriptions_listen=`. +The event vocabulary (the four `ServerEvent` dataclasses and the +`_meta` subscription-id key) is defined in `mcp.shared.subscriptions`, +shared with the client driver, and re-exported here. + Per the spec, the handler acknowledges first (the ack is the first frame on the stream), tags every frame with the listen request's JSON-RPC id under `_meta["io.modelcontextprotocol/subscriptionId"]`, and never delivers an @@ -24,7 +28,6 @@ import logging from collections.abc import Callable -from dataclasses import dataclass from typing import Any, Protocol import anyio @@ -33,56 +36,38 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_REQUEST, - NotificationParams, - PromptListChangedNotification, - ResourceListChangedNotification, - ResourceUpdatedNotification, - ResourceUpdatedNotificationParams, - ServerNotification, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequestParams, SubscriptionsListenResult, - ToolListChangedNotification, ) from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_to_notification, +) -logger = logging.getLogger(__name__) - -SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" -"""The `_meta` key carrying the subscription id on every listen-stream frame. - -The value is the `subscriptions/listen` request's JSON-RPC id, verbatim. -""" - - -@dataclass(frozen=True) -class ToolsListChanged: - """The server's tool list changed.""" - - -@dataclass(frozen=True) -class PromptsListChanged: - """The server's prompt list changed.""" - - -@dataclass(frozen=True) -class ResourcesListChanged: - """The server's resource list changed.""" - - -@dataclass(frozen=True) -class ResourceUpdated: - """The resource at `uri` changed and may need to be read again.""" - - uri: str - +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "InMemorySubscriptionBus", + "ListenHandler", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "SubscriptionBus", + "ToolsListChanged", +] -ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated -"""An event a server publishes for delivery to listen subscribers.""" +logger = logging.getLogger(__name__) class SubscriptionBus(Protocol): @@ -185,17 +170,6 @@ def _event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: Ser return event.uri in uris -def _event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: - """Build the stamped wire notification for `event`.""" - if isinstance(event, ToolsListChanged): - return ToolListChangedNotification(params=NotificationParams(_meta=meta)) - if isinstance(event, PromptsListChanged): - return PromptListChangedNotification(params=NotificationParams(_meta=meta)) - if isinstance(event, ResourcesListChanged): - return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) - return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) - - class ListenHandler: """Serves `subscriptions/listen`: one call is one subscription stream. @@ -273,7 +247,7 @@ def deliver(event: ServerEvent) -> None: ) async for event in recv: await ctx.session.send_notification( - _event_to_notification(event, meta), related_request_id=subscription_id + event_to_notification(event, meta), related_request_id=subscription_id ) finally: _safe_unsubscribe(unsubscribe) diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index 16360d314..1cc8a87b2 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -34,12 +34,24 @@ "OnRequest", "Outbound", "ProgressFnT", + "as_request_id", "coerce_request_id", ] TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) +def as_request_id(value: object) -> RequestId | None: + """Narrow an untyped wire value to a `RequestId`, or None. + + Rejects bool explicitly: bool subclasses int, so True would alias + request id 1 in every correlation table keyed by id. + """ + if isinstance(value, str | int) and not isinstance(value, bool): + return value + return None + + def coerce_request_id(request_id: RequestId) -> RequestId: """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 793c59bc7..673d01420 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -46,6 +46,7 @@ OnNotify, OnRequest, ProgressFnT, + as_request_id, coerce_request_id, ) from mcp.shared.exceptions import MCPError, NoBackChannelError @@ -107,12 +108,8 @@ def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToke def cancelled_request_id_from_params(params: Mapping[str, Any] | None) -> RequestId | None: - """Read `params.requestId` from a `notifications/cancelled`; reject bool (True would alias request id 1).""" - match params: - case {"requestId": str() | int() as request_id} if not isinstance(request_id, bool): - return request_id - case _: - return None + """Read `params.requestId` from a `notifications/cancelled` (`as_request_id` shape rules).""" + return as_request_id((params or {}).get("requestId")) @dataclass(slots=True) diff --git a/src/mcp/shared/subscriptions.py b/src/mcp/shared/subscriptions.py new file mode 100644 index 000000000..56f951ede --- /dev/null +++ b/src/mcp/shared/subscriptions.py @@ -0,0 +1,99 @@ +"""The typed event vocabulary `subscriptions/listen` shares between server and client (2026-07-28, SEP-2575). + +A server publishes these events (`mcp.server.subscriptions`); a client +iterating a `Subscription` (`mcp.client.subscriptions`) receives the same +values back. The two conversion helpers map between events and their wire +notifications, one direction per side. + +Every event kind is a level trigger: it says "this changed, refetch if you +care", and carries no payload beyond identity - so two equal pending events +mean exactly what one means, which is what lets both sides bound their +buffers by deduplication. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from mcp_types import ( + NotificationParams, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerNotification, + ToolListChangedNotification, +) + +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "ToolsListChanged", + "event_from_notification", + "event_to_notification", +] + +SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" +"""The `_meta` key carrying the subscription id on every listen-stream frame. + +The value is the `subscriptions/listen` request's JSON-RPC id, verbatim. +""" + + +@dataclass(frozen=True) +class ToolsListChanged: + """The server's tool list changed.""" + + +@dataclass(frozen=True) +class PromptsListChanged: + """The server's prompt list changed.""" + + +@dataclass(frozen=True) +class ResourcesListChanged: + """The server's resource list changed.""" + + +@dataclass(frozen=True) +class ResourceUpdated: + """The resource at `uri` changed and may need to be read again.""" + + uri: str + + +ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated +"""An event a server publishes for delivery to listen subscribers.""" + + +def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: + """Build the stamped wire notification for `event` (the server's direction).""" + if isinstance(event, ToolsListChanged): + return ToolListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, PromptsListChanged): + return PromptListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, ResourcesListChanged): + return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) + return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) + + +def event_from_notification(notification: ServerNotification) -> ServerEvent | None: + """The event a listen-stream notification announces (the client's direction). + + Returns None for notification kinds that are not subscription events. + """ + match notification: + case ToolListChangedNotification(): + return ToolsListChanged() + case PromptListChangedNotification(): + return PromptsListChanged() + case ResourceListChangedNotification(): + return ResourcesListChanged() + case ResourceUpdatedNotification(params=params): + return ResourceUpdated(uri=params.uri) + case _: + return None diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f8c02c973..6c78503b9 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -35,7 +35,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION from pydantic import FileUrl -from mcp import MCPError +from mcp import MCPDeprecationWarning, MCPError from mcp.client._memory import InMemoryTransport from mcp.client._transport import TransportStreams from mcp.client.client import Client @@ -310,13 +310,15 @@ async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotif async def test_client_subscribe_resource(simple_server: Server): async with Client(simple_server, mode="legacy") as client: - result = await client.subscribe_resource("memory://test") + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + result = await client.subscribe_resource("memory://test") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) async def test_client_unsubscribe_resource(simple_server: Server): async with Client(simple_server, mode="legacy") as client: - result = await client.unsubscribe_resource("memory://test") + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + result = await client.unsubscribe_resource("memory://test") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 2a53f67ce..cd180102d 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -44,6 +44,7 @@ from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY from mcp.shared.transport_context import TransportContext _SendToClient = anyio.streams.memory.MemoryObjectSendStream[SessionMessage | Exception] @@ -1808,3 +1809,40 @@ async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestPa result = await client.session.read_resource("memory://r", allow_input_required=True) assert isinstance(result, types.InputRequiredResult) assert result.request_state == "resource-state" + + +@pytest.mark.anyio +async def test_a_late_ack_for_a_closed_driver_listen_is_dropped(): + """Ack consumption is namespaced, not timing-dependent: an ack for a + driver-minted listen id whose route is already unregistered (enter timed + out, context exited) is dropped instead of leaking to message_handler.""" + seen: list[object] = [] + follow_up = anyio.Event() + + async def handler(msg: object) -> None: + seen.append(msg) + follow_up.set() + + async with raw_client_session(message_handler=handler) as (session, to_client, _): + _set_negotiated_version(session, "2026-07-28") + session._register_listen_route("listen-99") # pyright: ignore[reportPrivateUsage] + session._unregister_listen_route("listen-99") # pyright: ignore[reportPrivateUsage] + await to_client.send( + SessionMessage( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/subscriptions/acknowledged", + params={ + "notifications": {"toolsListChanged": True}, + "_meta": {SUBSCRIPTION_ID_META_KEY: "listen-99"}, + }, + ) + ) + ) + # A follow-up notification proves processing moved past the dropped ack. + await to_client.send( + SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed", params={})) + ) + with anyio.fail_after(5): + await follow_up.wait() + assert [type(message).__name__ for message in seen] == ["ToolListChangedNotification"] diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index defda41f8..aae2f4e06 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -18,6 +18,7 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, JSONRPCError, @@ -583,3 +584,44 @@ def handler(request: httpx.Request) -> httpx.Response: ) await parked.closed.wait() assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"] + + +class _DyingSSEStream(httpx.AsyncByteStream): + """Emits one id-less comment then breaks - a non-resumable stream dropping.""" + + def __init__(self) -> None: + self.opened = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + yield b": hello\n\n" + raise httpx.ReadError("connection reset") + + async def aclose(self) -> None: + pass + + +@pytest.mark.anyio +async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None: + """A per-request SSE stream that dies having carried no event ids can never + deliver its response; the transport resolves the waiter with CONNECTION_CLOSED + instead of leaving the request pending for the session's lifetime (a listen + stream's consumer would otherwise hang instead of learning it is lost).""" + dying = _DyingSSEStream() + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == CONNECTION_CLOSED diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py new file mode 100644 index 000000000..2339b11d9 --- /dev/null +++ b/tests/client/test_subscriptions.py @@ -0,0 +1,536 @@ +"""Behavioral tests for the client-side `subscriptions/listen` driver. + +Everything runs through the public API (`Client.listen`) against in-process +servers, per the repo's mirror rule for `src/mcp/client/subscriptions.py`. +Wire-shape assertions (subscription-id tagging, ack-first ordering) live in +the interaction suite; these tests pin the driver's contract. +""" + +from itertools import count +from typing import Any + +import anyio +import mcp_types as types +import pytest +from mcp_types import SubscriptionFilter + +import mcp.client.subscriptions as subscriptions_module +from mcp import Client, MCPError +from mcp.client.session import ClientSession +from mcp.client.subscriptions import ( + ListenNotSupportedError, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + Subscription, + SubscriptionLost, + ToolsListChanged, + listen, +) +from mcp.server import Server, ServerRequestContext +from mcp.server.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + InMemorySubscriptionBus, + ListenHandler, +) +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair +from mcp.shared.dispatcher import CallOptions + +pytestmark = pytest.mark.anyio + + +def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]: + """A lowlevel server whose only feature is serving listen streams from `bus`.""" + handler = ( + ListenHandler(bus) if max_subscriptions is None else ListenHandler(bus, max_subscriptions=max_subscriptions) + ) + return Server("subs", on_subscriptions_listen=handler) + + +async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) -> dict[str, Any]: + """Send a hand-rolled ack for a scripted listen handler; returns the stamped meta.""" + assert ctx.request_id is not None + meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: ctx.request_id} + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta) + ), + related_request_id=ctx.request_id, + ) + return meta + + +async def test_listen_surfaces_the_honored_filter_and_subscription_id(): + """Entering waits for the ack: `honored` and `subscription_id` are populated + before the first event is consumed.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + assert isinstance(sub, Subscription) + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + assert isinstance(sub.subscription_id, str) + assert sub.subscription_id.startswith("listen-") + + +async def test_listen_delivers_all_four_typed_event_kinds(): + """Bus publishes come back as the same typed event values, in order.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, + prompts_list_changed=True, + resources_list_changed=True, + resource_subscriptions=["note://todo"], + ) as sub: + for event in ( + ToolsListChanged(), + PromptsListChanged(), + ResourcesListChanged(), + ResourceUpdated(uri="note://todo"), + ): + await bus.publish(event) + assert await anext(sub) == event + + +async def test_unconsumed_duplicate_events_coalesce(): + """Events are level triggers: duplicates pending consumption collapse to one, + so a slow consumer wakes once per distinct fact, not once per publish.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + for _ in range(3): + await bus.publish(ToolsListChanged()) + await bus.publish(ResourceUpdated(uri="note://todo")) + await anyio.wait_all_tasks_blocked() + assert await anext(sub) == ToolsListChanged() + # The duplicates collapsed: the next event is the resource update. + assert await anext(sub) == ResourceUpdated(uri="note://todo") + + +async def test_graceful_server_close_ends_the_loop_cleanly(): + """The server's deliberate close (the empty listen result) ends iteration + without an exception - a clean end, not a loss.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + server = Server("subs", on_subscriptions_listen=handler) + events: list[object] = [] + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + handler.close() + events.extend([event async for event in sub]) + # The event published before the close was still delivered. + assert events == [ToolsListChanged()] + + +async def test_abrupt_stream_end_raises_subscription_lost(): + """A stream that dies without the graceful result raises `SubscriptionLost` + from iteration, with the underlying error chained.""" + proceed = anyio.Event() + + async def dropping_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + await _ack(ctx, params.notifications) + await proceed.wait() + raise MCPError(types.INTERNAL_ERROR, "stream torn down") + + server = Server("subs", on_subscriptions_listen=dropping_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost) as exc_info: # pragma: no branch + await anext(sub) + assert isinstance(exc_info.value.__cause__, MCPError) + assert exc_info.value.__cause__.error.message == "stream torn down" + + +async def test_listen_on_a_legacy_connection_raises_the_typed_steer(): + """On a 2025 connection `listen` fails fast with the typed error steering to + the legacy verbs, instead of leaking a -32601 from the wire.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus), mode="legacy") as client: + with anyio.fail_after(5): + # Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body. + with pytest.raises(ListenNotSupportedError) as exc_info: # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.negotiated_version == "2025-11-25" + assert "subscribe_resource" in str(exc_info.value) + + +async def test_server_rejection_raises_from_enter_not_from_iteration(): + """A server without the listen handler rejects the request; the error surfaces + immediately from entering the context (raise, don't degrade).""" + server = Server("no-listen") + async with Client(server) as client: + with anyio.fail_after(5): + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.error.code == types.METHOD_NOT_FOUND + + +async def test_immediate_result_without_ack_opens_already_closed(): + """A server answering with the bare result and no ack yields a subscription + that is already gracefully over: empty honored filter, no events.""" + + async def degenerate_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + server = Server("subs", on_subscriptions_listen=degenerate_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.honored == SubscriptionFilter() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost(): + """A server tearing the stream down with notifications/cancelled (the + stream-transport spelling) surfaces as a lost subscription.""" + proceed = anyio.Event() + + async def cancelling_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await proceed.wait() + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)), + related_request_id=ctx.request_id, + ) + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=cancelling_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost): # pragma: no branch + await anext(sub) + + +async def test_exiting_the_context_frees_the_server_slot(): + """Leaving the block ends the subscription server-side: with a one-slot + handler, a second listen succeeds only because the first was released.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus, max_subscriptions=1)) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as first: + assert first.honored.tools_list_changed is True + async with client.listen(tools_list_changed=True) as second: # pragma: no branch + assert second.honored.tools_list_changed is True + assert second.subscription_id != first.subscription_id + + +async def test_concurrent_subscriptions_demux_independently(): + """Two open subscriptions each receive only their own filter's events.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with ( # pragma: no branch + client.listen(tools_list_changed=True) as tools_sub, + client.listen(resource_subscriptions=["note://todo"]) as notes_sub, + ): + await bus.publish(ToolsListChanged()) + await bus.publish(ResourceUpdated(uri="note://todo")) + assert await anext(tools_sub) == ToolsListChanged() + assert await anext(notes_sub) == ResourceUpdated(uri="note://todo") + # Neither stream received the other's event. + await bus.publish(ToolsListChanged()) + assert await anext(tools_sub) == ToolsListChanged() + + +async def test_change_notifications_still_reach_message_handler(): + """The demux tees: a delivered event's notification still flows to + message_handler (cache eviction and observers keep working); the ack is + driver state and is consumed.""" + bus = InMemorySubscriptionBus() + seen: list[str] = [] + + async def on_message(message: object) -> None: + # The ack never reaches the handler - it is driver state, consumed by the demux. + assert not isinstance(message, types.SubscriptionsAcknowledgedNotification) + if isinstance(message, types.ToolListChangedNotification): # pragma: no branch + seen.append("tools-changed") + + async with Client(_bus_server(bus), message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + assert await anext(sub) == ToolsListChanged() + await anyio.wait_all_tasks_blocked() + assert seen == ["tools-changed"] + + +async def test_enter_times_out_when_the_ack_never_arrives(): + """The ack wait rides the session's read timeout, so a wedged server cannot + hang the open forever.""" + + async def silent_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=silent_listen) + async with Client(server, read_timeout_seconds=0.05) as client: + with anyio.fail_after(5): + with pytest.raises(TimeoutError): # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + + +async def test_an_open_stream_outlives_the_session_read_timeout(): + """The listen request itself is exempt from the read timeout: the stream + stays open and delivers long after the per-request deadline passed.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + # Real clock on purpose: this pins a timeout feature. + await anyio.sleep(0.2) + await bus.publish(ToolsListChanged()) + assert await anext(sub) == ToolsListChanged() + + +async def test_a_duplicate_ack_does_not_overwrite_the_honored_filter(): + """The first ack wins; a later conflicting ack is a no-op.""" + proceed = anyio.Event() + + async def double_acking_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await _ack(ctx, SubscriptionFilter()) + await proceed.wait() + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + server = Server("subs", on_subscriptions_listen=double_acking_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.honored.tools_list_changed is True + proceed.set() + + +async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered(): + """A stamped notification that is not a change event (a log line on the + stream) never surfaces as an event; it flows to message_handler as usual.""" + proceed = anyio.Event() + + async def logging_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + meta = await _ack(ctx, params.notifications) + await ctx.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams(level="info", data="not an event", _meta=meta) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta=meta) + + logged: list[str] = [] + + async def on_message(message: object) -> None: + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + logged.append(str(message.params.data)) + + server = Server("subs", on_subscriptions_listen=logging_listen) + async with Client(server, message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await anyio.wait_all_tasks_blocked() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + assert logged == ["not an event"] + + +async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost(): + """Closing the client while a watcher task is parked on the stream must not + strand it: teardown settles every open route as lost.""" + bus = InMemorySubscriptionBus() + outcome: list[str] = [] + entered = anyio.Event() + + async def consume(client: Client) -> None: + with pytest.raises(SubscriptionLost): + async with client.listen(tools_list_changed=True) as sub: + entered.set() + await anext(sub) + outcome.append("lost") + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + async with Client(_bus_server(bus)) as client: # pragma: no branch + tg.start_soon(consume, client) + await entered.wait() + # The client exited above while the watcher was still parked on the + # stream; teardown settles the route, unblocking it with a lost end. + assert outcome == ["lost"] + + +async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter(): + """A stream torn down before it was ever acknowledged is a failed open: + enter raises instead of yielding a handle with a fabricated empty filter.""" + + async def cancel_first_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)), + related_request_id=ctx.request_id, + ) + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=cancel_first_listen) + async with Client(server) as client: + with anyio.fail_after(5): + with pytest.raises(SubscriptionLost, match="before it was acknowledged"): # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + + +async def test_listen_on_an_exited_session_raises_and_leaks_no_route(): + """Opening against a session whose context already exited fails loudly, and + the demux registration does not outlive the failed open.""" + bus = InMemorySubscriptionBus() + client = Client(_bus_server(bus)) + async with client: + session = client.session + with pytest.raises(RuntimeError): + await listen(session, tools_list_changed=True).__aenter__() + assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + + +async def test_listen_on_a_never_entered_session_raises_runtime_error(): + """An adopted-but-never-entered session has no task group to drive the stream.""" + dispatcher, _peer = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=dispatcher) + session.adopt( + types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=types.ServerCapabilities(), + server_info=types.Implementation(name="stub", version="0"), + ) + ) + with pytest.raises(RuntimeError, match="entered session"): + await listen(session, tools_list_changed=True).__aenter__() + assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + + +async def test_a_retained_handle_after_exit_does_not_serve_stale_events(): + """Leaving the block abandons the backlog: a stashed handle must not replay + buffered events as if they were live.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: + await bus.publish(ToolsListChanged()) + await anyio.wait_all_tasks_blocked() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler(): + """Acks for ids the driver never minted flow to message_handler - the raw + escape-hatch listen (send_request directly) observes its ack there.""" + proceed = anyio.Event() + + async def stray_acking_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams( + notifications=SubscriptionFilter(), _meta={SUBSCRIPTION_ID_META_KEY: 424242} + ) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + handled: list[str] = [] + + async def on_message(message: object) -> None: + handled.append(type(message).__name__) + + server = Server("subs", on_subscriptions_listen=stray_acking_listen) + async with Client(server, message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await anyio.wait_all_tasks_blocked() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + assert "SubscriptionsAcknowledgedNotification" in handled + + +async def test_a_bare_string_for_resource_subscriptions_is_rejected(): + """`resource_subscriptions="uri"` would explode into per-character URIs; the + classic footgun is rejected before anything touches the wire.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with pytest.raises(TypeError, match="sequence of URIs"): + await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType] + + +async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A raw caller occupying the driver's next minted id fails that ONE listen + with a typed error from enter; the session survives and the next listen + (a fresh id) opens normally.""" + monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000)) + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: # pragma: no branch + raw_scope = anyio.CancelScope() + + async def raw_listen() -> None: + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=SubscriptionFilter(tools_list_changed=True) + ) + ) + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": "listen-7000"} + client.session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + with raw_scope: + await client.session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + + tg.start_soon(raw_listen) + await anyio.wait_all_tasks_blocked() + with pytest.raises(MCPError) as exc_info: + await client.listen(tools_list_changed=True).__aenter__() + assert "already in flight" in exc_info.value.error.message + raw_scope.cancel() + # The session is intact: the next listen mints a fresh id and opens. + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.subscription_id == "listen-7001" diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index af5e69249..3d70371f5 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -5,7 +5,7 @@ from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 -from mcp import Client, MCPError +from mcp import Client, MCPDeprecationWarning, MCPError from mcp.shared.metadata_utils import get_display_name # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -128,7 +128,9 @@ async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> N assert client.server_capabilities.resources is not None assert client.server_capabilities.resources.subscribe is True with pytest.raises(MCPError) as exc_info: - await client.subscribe_resource("catalog://genres") + # The verb is itself deprecated; the modern wire also rejects it. + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + await client.subscribe_resource("catalog://genres") # pyright: ignore[reportDeprecated] assert exc_info.value.error.code == -32601 assert exc_info.value.error.message == "Method not found" diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index b664afe98..99001c3d8 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -6,7 +6,7 @@ import mcp_types as types import pytest -from docs_src.subscriptions import tutorial001, tutorial002 +from docs_src.subscriptions import tutorial001, tutorial002, tutorial003 from mcp import Client from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged @@ -136,3 +136,22 @@ async def listen() -> None: assert isinstance(stream.received[2], types.ResourceUpdatedNotification) tg.cancel_scope.cancel() + + +async def test_client_listen_delivers_one_typed_event_then_closes() -> None: + """tutorial003: `Client.listen` yields a typed event for the subscribed URI and + leaving the block closes the stream (two clients, one server instance).""" + results: list[str] = [] + + async def watch() -> None: + results.append(await tutorial003.watch_todo()) + + with anyio.fail_after(10): + async with anyio.create_task_group() as tg: + tg.start_soon(watch) + # The watcher parks on its stream (the ack round trip is complete) + # before the edit is published. + await anyio.wait_all_tasks_blocked() + async with Client(tutorial001.mcp) as editor: # pragma: no branch + await editor.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + assert results == ["changed: note://todo"] diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index ada4b7fa0..95f873b79 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1182,6 +1182,50 @@ def __post_init__(self) -> None: removed_in="2026-07-28", note="removed in 2026-07-28 (SEP-2575); resources/unsubscribe replaced by subscriptions/listen.", ), + "subscriptions:listen:client:honored-surfacing": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment", + behavior=( + "Entering Client.listen() waits for the server's acknowledgment and surfaces the honored " + "filter subset on the handle, so the client can check it against what it requested (spec SHOULD)." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:iteration": Requirement( + source="sdk", + behavior=( + "An open subscription is an async iterator of typed change events; delivered notifications " + "still tee to message_handler so caching and observers keep working." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:graceful-close": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#cancellation", + behavior=( + "The server's empty subscriptions/listen result (its deliberate close) ends iteration cleanly " + "after buffered events drain; no exception is raised." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:lost": Requirement( + source="sdk", + behavior=( + "A listen stream that ends without the graceful result raises SubscriptionLost from iteration; " + "there is no automatic re-listen." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:era-guard": Requirement( + source="sdk", + behavior=( + "Client.listen() on a pre-2026 connection raises ListenNotSupportedError steering to " + "subscribe_resource/message_handler instead of leaking a wire -32601." + ), + removed_in="2026-07-28", + note=( + "removed_in scopes the matrix to the 2025 cells deliberately: the behavior under test is the " + "guard on connections where the method does not exist." + ), + ), "resources:updated-notification": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", behavior=( diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index 44ab33e64..db7d4dfe6 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -203,6 +203,7 @@ async def list_resource_templates( ) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe") async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result.""" @@ -214,11 +215,12 @@ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeR server = Server("library", on_subscribe_resource=subscribe_resource) async with connect(server) as client: - result = await client.subscribe_resource("file:///watched.txt") + result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe:capability-required") async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None: """Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND. @@ -237,13 +239,14 @@ async def list_resources( async with connect(server) as client: with pytest.raises(MCPError) as exc_info: - await client.subscribe_resource("file:///watched.txt") + await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert exc_info.value.error == snapshot( ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe") ) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:unsubscribe") async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Unsubscribing from a resource delivers the URI to the server's unsubscribe handler.""" @@ -255,7 +258,7 @@ async def unsubscribe_resource(ctx: ServerRequestContext, params: types.Unsubscr server = Server("library", on_unsubscribe_resource=unsubscribe_resource) async with connect(server) as client: - result = await client.unsubscribe_resource("file:///watched.txt") + result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) diff --git a/tests/interaction/lowlevel/test_subscriptions.py b/tests/interaction/lowlevel/test_subscriptions.py new file mode 100644 index 000000000..ce47f4c47 --- /dev/null +++ b/tests/interaction/lowlevel/test_subscriptions.py @@ -0,0 +1,64 @@ +"""Client.listen stream endings against lowlevel servers over the connect matrix.""" + +from typing import Any + +import anyio +import mcp_types as types +import pytest + +from mcp import MCPError +from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged +from mcp.server import Server, ServerRequestContext +from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +@requirement("subscriptions:listen:client:graceful-close") +async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None: + """`ListenHandler.close()` sends the stamped result as the final frame; the + client loop drains what was published first, then ends without an exception.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + server = Server("subs", on_subscriptions_listen=handler) + events: list[object] = [] + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + handler.close() + events.extend([event async for event in sub]) + assert events == [ToolsListChanged()] + + +@requirement("subscriptions:listen:client:lost") +async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None: + """A server erroring the listen request after the ack (an abrupt end, not the + graceful close) surfaces as SubscriptionLost at the iteration site.""" + proceed = anyio.Event() + + async def dropping_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams( + notifications=params.notifications, + _meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}, + ) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + raise MCPError(types.INTERNAL_ERROR, "stream torn down") + + server = Server("subs", on_subscriptions_listen=dropping_listen) + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost): # pragma: no branch + await anext(sub) diff --git a/tests/interaction/mcpserver/test_subscriptions.py b/tests/interaction/mcpserver/test_subscriptions.py new file mode 100644 index 000000000..bf02ec4ca --- /dev/null +++ b/tests/interaction/mcpserver/test_subscriptions.py @@ -0,0 +1,65 @@ +"""Client.listen against MCPServer over the connect matrix (2026-07-28).""" + +import anyio +import pytest + +from mcp.client.subscriptions import ListenNotSupportedError, ResourceUpdated, ToolsListChanged +from mcp.server.mcpserver import Context, MCPServer +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +def _notebook() -> MCPServer: + """A server whose tools publish both change kinds through their Context.""" + mcp = MCPServer("notebook") + + @mcp.tool() + async def touch_tools(ctx: Context) -> str: + await ctx.notify_tools_changed() + return "ok" + + @mcp.tool() + async def edit_note(name: str, ctx: Context) -> str: + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + return mcp + + +@requirement("subscriptions:listen:client:honored-surfacing") +@requirement("subscriptions:listen:client:iteration") +async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None: + """Entering waits for the acknowledgment (the honored filter is on the handle + before any event), and iteration yields the typed events the server's + notify calls produce - only the kinds this stream opted in to.""" + mcp = _notebook() + async with connect(mcp) as client: + with anyio.fail_after(10): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + + await client.call_tool("edit_note", {"name": "journal"}) # unsubscribed URI: silent + await client.call_tool("edit_note", {"name": "todo"}) + assert await anext(sub) == ResourceUpdated(uri="note://todo") + + await client.call_tool("touch_tools", {}) + assert await anext(sub) == ToolsListChanged() + + +@requirement("subscriptions:listen:client:era-guard") +async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None: + """On 2025-era connections the guard fires before anything touches the wire, + steering to the legacy verbs.""" + mcp = _notebook() + async with connect(mcp) as client: + with anyio.fail_after(10): + # Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body. + with pytest.raises(ListenNotSupportedError) as exc_info: + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.negotiated_version == client.session.protocol_version + assert "subscribe_resource" in str(exc_info.value) From dcdd2b825ddf31f7141b9662d8dee306b217ce28 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:09:50 +0000 Subject: [PATCH 3/8] Keep the docs watch-loop snippet ASCII The docs-example checker pipes snippets to ruff in the platform encoding; an em-dash in a code comment arrives as invalid UTF-8 on Windows runners. --- docs/handlers/subscriptions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 852814b7f..2967267f4 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -107,7 +107,7 @@ async def watch(client: Client, uri: str) -> None: async for _event in sub: await client.read_resource(uri) except SubscriptionLost: - continue # transport dropped — re-listen + continue # transport dropped - re-listen else: break # the server ended it deliberately ``` From a3faba3944a04c73bfbf539fa88095c6027b946d Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:21:12 +0000 Subject: [PATCH 4/8] Harden the listen driver against receive-order races and hostile peers Move the client's listen-stream demux to a synchronous notification intercept on the dispatcher's receive path, where cancelled/progress interception already lives: acks, events, and teardown signals now advance in wire order relative to the listen result, so a graceful close can no longer outrun the events that preceded it or clobber the acknowledged filter with the fabricated empty one. Ack consumption keys on the live route registry alone (the per-session id set is gone), route admission enforces the honored filter - loose on URIs, which the spec lets be sub-resources, with a capped backlog settling the route lost against floods - and the response-cache eviction barrier moved to the consumption point, held pending until it completes so a cancelled consumer cannot lose a level trigger. On the transport, a request whose SSE stream can never answer - a non-resumable drop or an exhausted reconnection budget - now resolves with a synthesized error, contained against teardown races. Docs: a graceful close is not "stop watching" (servers shed load by closing gracefully, including this SDK's ListenHandler); the watch loop re-listens on both endings, and sub.honored is the delivery contract, not catalog state. --- docs/handlers/subscriptions.md | 14 +- src/mcp/client/client.py | 26 +++- src/mcp/client/session.py | 112 ++++++++------ src/mcp/client/streamable_http.py | 44 ++++-- src/mcp/client/subscriptions.py | 128 ++++++++++++--- src/mcp/server/subscriptions.py | 18 +-- src/mcp/shared/direct_dispatcher.py | 15 +- src/mcp/shared/dispatcher.py | 43 +++++- src/mcp/shared/jsonrpc_dispatcher.py | 12 +- src/mcp/shared/subscriptions.py | 57 +++++-- tests/client/test_send_request_mcp_name.py | 3 +- tests/client/test_session.py | 134 +++++++++++++++- tests/client/test_session_claims.py | 3 +- tests/client/test_streamable_http.py | 60 ++++++- tests/client/test_subscriptions.py | 172 +++++++++++++++++++++ tests/shared/test_dispatcher.py | 72 ++++++++- 16 files changed, 781 insertions(+), 132 deletions(-) diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 2967267f4..075a4317c 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -94,9 +94,9 @@ Consuming a subscription is one context manager: ``` * `client.listen(...)` takes the filter as keyword arguments — they mirror the wire `SubscriptionFilter` field for field. Entering sends the request and returns once the server's acknowledgment arrives, so `sub.honored` (the subset the server agreed to deliver) is always there before the first event. -* Iteration yields the same four typed events the server publishes: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`. An event is a cue to refetch — it carries no payload beyond identity, and duplicates pending consumption collapse into one. +* Iteration yields the same four typed events the server publishes: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)` — where the URI may be a sub-resource of one you subscribed to, at the server's discretion. An event is a cue to refetch — it carries no payload beyond identity, and duplicates pending consumption collapse into one. * Leaving the block ends the subscription, with the transport's own spelling: over streamable HTTP the request's response stream is closed (that is the 2026 cancellation signal), on stream transports `notifications/cancelled` is sent. -* The stream's two endings are control flow. The server closing gracefully simply ends the `async for`; an abrupt drop raises `SubscriptionLost`. There is no replay and no automatic re-listen — a client that reconnects refetches what it depends on: +* The stream's two endings are control flow. The server closing gracefully simply ends the `async for`; an abrupt drop raises `SubscriptionLost`. The distinction is diagnostic — a clean end versus a connection worth suspecting — not a difference in what to do next: either way the stream is gone, nothing is replayed, and a watcher that still cares re-listens and refetches. Servers close streams gracefully for their own reasons — shutdown, or shedding a subscriber whose backlog grew past bounds, as this SDK's `ListenHandler` does — so a graceful close is not a signal to stop watching: ```python async def watch(client: Client, uri: str) -> None: @@ -107,12 +107,14 @@ async def watch(client: Client, uri: str) -> None: async for _event in sub: await client.read_resource(uri) except SubscriptionLost: - continue # transport dropped - re-listen - else: - break # the server ended it deliberately + pass + # Graceful close or abrupt drop, the stream is gone either way. Back + # off before re-listening - a graceful close may be the server + # shedding load, and reconnecting instantly recreates the pressure. + await anyio.sleep(1) ``` -* Checking the acknowledgment (the spec's client SHOULD) is reading `sub.honored` — for example, `if not sub.honored.prompts_list_changed:` the server has no prompts to watch. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id. +* Checking the acknowledgment (the spec's client SHOULD) is reading `sub.honored` — the kinds this stream will actually receive. A server may narrow the filter it agrees to honor (a multi-tenant server declining a URI, say), and `sub.honored` is that delivery contract — it says nothing about what exists in the catalog. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id. * Tool calls and other requests run freely beside an open stream — from the same task between events, or from sibling tasks sharing the client. A watcher task that refetches inside its event loop is the intended pattern, not a re-entrancy hazard. * `listen()` requires a 2026-07-28 connection and raises `ListenNotSupportedError` on older ones, steering to the deprecated `subscribe_resource` and `message_handler` spelling those wires use. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index cd54ee0f5..62d581eb7 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -58,7 +58,7 @@ SamplingFnT, ) from mcp.client.streamable_http import streamable_http_client -from mcp.client.subscriptions import Subscription +from mcp.client.subscriptions import ServerEvent, Subscription from mcp.client.subscriptions import listen as _listen from mcp.server import Server from mcp.server.mcpserver import MCPServer @@ -69,6 +69,7 @@ from mcp.shared.extension import validate_extension_identifier from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import event_to_notification logger = logging.getLogger(__name__) @@ -684,7 +685,8 @@ def listen( tools = await client.list_tools() # refetch on change A graceful server close ends the loop; an abrupt drop raises - `SubscriptionLost` (re-listen and refetch - there is no replay). + `SubscriptionLost`. Either way the stream is gone and there is no + replay: a client that keeps watching re-listens and refetches. Exiting the context ends the subscription. Multiple subscriptions may be open concurrently. @@ -702,8 +704,28 @@ def listen( prompts_list_changed=prompts_list_changed, resources_list_changed=resources_list_changed, resource_subscriptions=resource_subscriptions, + on_event=self._evict_for_listen_event if self._response_cache is not None else None, ) + async def _evict_for_listen_event(self, event: ServerEvent) -> None: + """Finish response-cache eviction before a listen consumer can refetch. + + The eviction wrapper on message_handler runs on a spawned path; the + consumer's iterator would otherwise wake first, refetch through a + still-warm entry, and - events being deduplicated level triggers - + never get a second wake to correct it. The event's notification still + tees to that wrapper, so the same eviction fires twice; that is + deliberate (eviction is idempotent, and the tee-path one covers + non-iterating consumers sharing this cache). Same containment boundary + as `_evicting_message_handler`: a cache fault must not block delivery. + """ + cache = self._response_cache + assert cache is not None # installed as the event barrier only when a cache exists + try: + await cache.evict_for_notification(event_to_notification(event, {})) + except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery + logger.exception("Response cache eviction failed; the event is still delivered") + @deprecated( "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", category=MCPDeprecationWarning, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 4e49988e6..a9861a570 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -50,10 +50,10 @@ mcp_param_headers, x_mcp_header_map, ) -from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder -from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_notification +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire from mcp.shared.transport_context import TransportContext DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -365,11 +365,11 @@ def __init__( self._task_group: anyio.abc.TaskGroup | None = None # subscriptions/listen demux routes, keyed by the listen request's id # (verbatim-typed: a plain dict already keeps 1 and "1" distinct). + # Fed by `_intercept_notification` on the dispatcher's receive path; + # membership alone decides ack consumption, so a closed subscription + # releases its id completely - raw escape-hatch listens (never + # registered here) keep receiving their acks via message_handler. self._listen_routes: dict[RequestId, ListenRoute] = {} - # Every id the driver ever minted on this session: a late ack for a - # closed driver listen must be dropped, while raw escape-hatch listens - # (never in this set) keep receiving their acks via message_handler. - self._driver_listen_ids: set[RequestId] = set() if dispatcher is not None: if read_stream is not None or write_stream is not None: raise ValueError("pass read_stream/write_stream or dispatcher, not both") @@ -398,7 +398,9 @@ async def __aenter__(self) -> Self: for binding in self._notification_bindings.values(): send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE) self._binding_queues[binding.method] = (send, receive) - await self._task_group.start(self._dispatcher.run, self._on_request, self._on_notify) + await self._task_group.start( + self._dispatcher.run, self._on_request, self._on_notify, self._intercept_notification + ) for binding in self._notification_bindings.values(): _, receive = self._binding_queues[binding.method] self._task_group.start_soon(self._deliver_bound_notifications, binding, receive) @@ -1248,7 +1250,6 @@ def _register_listen_route(self, request_id: RequestId) -> ListenRoute: """Create the demux route for a listen request id; the caller registers BEFORE sending.""" route = ListenRoute() self._listen_routes[request_id] = route - self._driver_listen_ids.add(request_id) return route def _unregister_listen_route(self, request_id: RequestId) -> None: @@ -1266,21 +1267,60 @@ def _settle_listen_routes_closed(self) -> None: route.settle("lost", error=closed) self._listen_routes.clear() - def _listen_subscription_id(self, notification: types.ServerNotification) -> RequestId | None: - """The frame's `_meta` subscription id, shape-checked. - - The `as_request_id` guard is not a tripwire: on pre-2026 wires the meta - key is untyped, so a non-id (even unhashable) value is constructible - and would fail the dict lookup; 2026 surface validation already - rejects those shapes. + def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) -> bool: + """Wire-order listen demux, run by the dispatcher on its receive path. + + Route bookkeeping must advance in receive order relative to the listen + request's own result: the result resolves synchronously on this same + path, so an ack or event handled on the spawned `_on_notify` path could + lose the race and be dropped after the stream settled - a graceful + close swallowing the events that preceded it. Only the synchronous + bookkeeping happens here; the user-facing tee (message_handler, + logging) stays on the spawned path. + + Returns True to consume the frame: an ack for a live driver route is + driver state, never surfaced. Raw escape-hatch listens have no route + registered and keep observing their acks via message_handler - as does + a stray ack for an already-closed driver id, whose registration is gone. + + The `as_request_id` guard is not a tripwire: this reads raw wire + dicts, where a non-id (even unhashable) `_meta` value is + constructible and would fail the dict lookup. """ - meta = notification.params.meta if notification.params is not None else None - return as_request_id(meta.get(SUBSCRIPTION_ID_META_KEY)) if meta is not None else None - - def _listen_route_for(self, notification: types.ServerNotification) -> ListenRoute | None: - """The route a listen-stream frame belongs to, by its `_meta` subscription id.""" - subscription_id = self._listen_subscription_id(notification) - return self._listen_routes.get(subscription_id) if subscription_id is not None else None + if not self._listen_routes: + return False + if method == "notifications/cancelled": + request_id = cancelled_request_id_from_params(params) + if request_id is not None and (listen_route := self._listen_routes.get(request_id)) is not None: + # A server-sent cancel naming one of our listen requests is + # that stream's teardown signal (the stream-transport spelling). + listen_route.settle("lost") + return False # _on_notify swallows every cancelled either way (v1 parity) + if params is None: + return False + meta = params.get("_meta") + if not isinstance(meta, Mapping): + return False + subscription_id = as_request_id(cast("Mapping[str, Any]", meta).get(SUBSCRIPTION_ID_META_KEY)) + if subscription_id is None or (listen_route := self._listen_routes.get(subscription_id)) is None: + return False + if method == "notifications/subscriptions/acknowledged": + raw_filter = params.get("notifications") + if raw_filter is None: + # The wire shape requires `notifications`; a frame without it is + # malformed, not an empty filter - leave it to the spawned + # path's validation warning rather than fabricating honored={}. + return False + try: + honored = types.SubscriptionFilter.model_validate(raw_filter) + except ValidationError: + # Malformed ack: leave it to the spawned path's validation warning. + return False + listen_route.set_acked(honored) + return True + if (event := event_from_wire(method, params)) is not None: + listen_route.deliver(event) + return False # events (and any other stamped frame) still tee as usual async def _on_notify( self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None @@ -1316,24 +1356,10 @@ async def _on_notify( logger.warning("Failed to validate notification: %s", method, exc_info=True) return if isinstance(notification, types.CancelledNotification): - # A server-sent cancel naming one of our listen requests is that - # stream's teardown signal (the stream-transport spelling); any - # other cancellation was already applied by the dispatcher. - # Neither is surfaced to message_handler. - cancelled_id = notification.params.request_id - if cancelled_id is not None and (listen_route := self._listen_routes.get(cancelled_id)) is not None: - listen_route.settle("lost") + # Never surfaced to message_handler (v1 parity): the dispatcher + # already applied any in-flight cancellation, and a cancel naming + # one of our listen streams was settled by the wire-order intercept. return - if isinstance(notification, types.SubscriptionsAcknowledgedNotification): - subscription_id = self._listen_subscription_id(notification) - if subscription_id is not None and subscription_id in self._driver_listen_ids: - # Driver state, never surfaced: a late ack for an already-closed - # driver listen is dropped rather than leaking to message_handler. - # Acks outside the driver's id namespace fall through - a raw - # escape-hatch listen observes its ack there. - if (listen_route := self._listen_routes.get(subscription_id)) is not None: - listen_route.set_acked(notification.params.notifications) - return try: if isinstance(notification, types.LoggingMessageNotification): await self._logging_callback(notification.params) @@ -1344,14 +1370,6 @@ async def _on_notify( # would otherwise fail the peer's send. A raising logging_callback # skips the message_handler tee for that notification (v1 parity). logger.exception("notification callback for %r raised", method) - # Deliver AFTER the tee: the caching layer's eviction wrapper runs in - # message_handler, and a consumer woken first could refetch into the - # stale entry - with deduplicated level-trigger events there would be - # no second wake to correct it. - if (listen_route := self._listen_route_for(notification)) is not None: - event = event_from_notification(notification) - if event is not None: - listen_route.deliver(event) async def _on_stream_exception(self, exc: Exception) -> None: """Deliver a transport-level fault to message_handler via a spawned task. diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index ee6fc06cf..09040ccc2 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -447,9 +447,25 @@ async def _handle_sse_response( # instead of leaving the request pending for the session's lifetime # (a listen stream's consumer would otherwise hang instead of # learning the subscription is lost). - error_data = ErrorData(code=CONNECTION_CLOSED, message="SSE stream ended without a response") - error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data)) - await ctx.read_stream_writer.send(error_msg) + await self._resolve_abandoned_request( + ctx.read_stream_writer, original_request_id, "SSE stream ended without a response" + ) + + async def _resolve_abandoned_request( + self, read_stream_writer: StreamWriter, request_id: RequestId, message: str + ) -> None: + """Resolve a request whose response can never arrive with a synthesized error. + + Best-effort, like the dispatcher's own resolution writes: the read + stream closing first means the session is being torn down and nobody + is waiting on this request anymore. + """ + error_data = ErrorData(code=CONNECTION_CLOSED, message=message) + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) + try: + await read_stream_writer.send(error_msg) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("read stream closed before request %r could be resolved", request_id) async def _handle_reconnection( self, @@ -459,9 +475,22 @@ async def _handle_reconnection( attempt: int = 0, ) -> None: """Reconnect with Last-Event-ID to resume stream after server disconnect.""" - # Bail if max retries exceeded - if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover + # Only requests reconnect: every caller reaches here from a request's + # response stream (`_handle_sse_response` asserts the same), so the + # original id to map responses onto is always present. + assert isinstance(ctx.session_message.message, JSONRPCRequest) + original_request_id = ctx.session_message.message.id + + if attempt >= MAX_RECONNECTION_ATTEMPTS: + # Give up AND resolve: without a synthesized error the waiter is + # pending for the session's lifetime, and a request with no read + # timeout (a listen stream) would hang its caller forever. logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") + await self._resolve_abandoned_request( + ctx.read_stream_writer, + original_request_id, + "SSE stream ended and reconnection attempts were exhausted", + ) return # Always wait - use server value or default @@ -471,11 +500,6 @@ async def _handle_reconnection( headers = self._prepare_headers() headers[LAST_EVENT_ID] = last_event_id - # Extract original request ID to map responses - original_request_id = None - if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.id - try: async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source: event_source.response.raise_for_status() diff --git a/src/mcp/client/subscriptions.py b/src/mcp/client/subscriptions.py index 929022b14..45e22590f 100644 --- a/src/mcp/client/subscriptions.py +++ b/src/mcp/client/subscriptions.py @@ -24,7 +24,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from contextlib import asynccontextmanager from itertools import count from typing import TYPE_CHECKING, Literal @@ -41,6 +41,7 @@ ResourceUpdated, ServerEvent, ToolsListChanged, + event_matches, ) if TYPE_CHECKING: @@ -48,6 +49,7 @@ __all__ = [ "ListenNotSupportedError", + "OnEvent", "PromptsListChanged", "ResourceUpdated", "ResourcesListChanged", @@ -61,6 +63,16 @@ _listen_ids = count(1) """Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints.""" +_MAX_PENDING_EVENTS = 1024 +"""Backstop on one route's unconsumed backlog, mirroring the server's `max_buffered_events`. + +Kind events are bounded by dedupe alone, but `ResourceUpdated` admission is +per-URI and the spec lets a server stamp sub-resources of a subscribed URI - +so distinct pending URIs are unbounded in principle. A peer that floods past +this cap costs the subscription (settled lost: re-listen and refetch), never +unbounded client memory. +""" + _SubscriptionEnd = Literal["graceful", "lost", "local"] @@ -89,15 +101,20 @@ class SubscriptionLost(RuntimeError): class ListenRoute: - """Demux state for one listen stream, owned by the session's notification path. + """Demux state for one listen stream, fed in wire order by the session. Package-internal (deliberately not in `__all__`): `ClientSession` - constructs and feeds it; `Subscription` consumes it. - - Everything here is synchronous on the event loop - the notification path - must never block on a slow consumer - and there is exactly one consumer - (the `Subscription`). Pending events deduplicate: every event kind is a - level trigger, so the backlog is bounded by the filter's width. + constructs it and feeds it from its notification intercept on the + dispatcher's receive path, so acks, events, and stream-teardown signals + land here in receive order - ordered against the listen request's own + result, which resolves on that same path. `Subscription` is the one + consumer. + + Everything here is synchronous on the event loop - the receive path must + never block on a slow consumer. Pending events deduplicate: every event + kind is a level trigger, so the backlog is bounded by the distinct + admissible events, with `_MAX_PENDING_EVENTS` as the backstop for the + URI class that admission deliberately leaves open. """ def __init__(self) -> None: @@ -105,6 +122,7 @@ def __init__(self) -> None: self.acked = anyio.Event() self.error: MCPError | None = None self.end: _SubscriptionEnd | None = None + self._honored_uris: frozenset[str] = frozenset() self._pending: dict[ServerEvent, None] = {} self._wake = anyio.Event() @@ -112,13 +130,42 @@ def set_acked(self, honored: types.SubscriptionFilter) -> None: """Record the acknowledged filter; the first ack wins, later ones are no-ops.""" if not self.acked.is_set(): self.honored = honored + self._honored_uris = frozenset(honored.resource_subscriptions or ()) self.acked.set() def deliver(self, event: ServerEvent) -> None: - """Queue `event` for the consumer; a duplicate of a pending event is dropped.""" - if self.end is None and event not in self._pending: - self._pending[event] = None - self._wake.set() + """Queue `event` for the consumer; duplicates of pending events are dropped. + + Only events within the honored filter are admitted, so a peer that + violates its own acknowledgment cannot reach the consumer. Kind events + match the honored flags exactly; a `ResourceUpdated` is admitted + whenever URI subscriptions were honored at all, because the spec lets + the stamped URI be a sub-resource of a subscribed one (schema: + ResourceUpdatedNotificationParams.uri). Before the ack there is no + honored filter and nothing is admissible (the spec makes the ack the + stream's first frame); after the stream has ended a frame can only be + post-close noise. `_MAX_PENDING_EVENTS` backstops the one backlog + admission cannot bound. + """ + if self.end is not None or self.honored is None: + return + if isinstance(event, ResourceUpdated): + admitted = bool(self._honored_uris) + else: + admitted = event_matches(self.honored, self._honored_uris, event) + if not admitted or event in self._pending: + return + if len(self._pending) >= _MAX_PENDING_EVENTS: + self.settle( + "lost", + error=MCPError( + types.INTERNAL_ERROR, + f"subscription backlog exceeded {_MAX_PENDING_EVENTS} unconsumed events; re-listen and refetch", + ), + ) + return + self._pending[event] = None + self._wake.set() def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: """Record the stream's end; the first reason wins. @@ -132,7 +179,12 @@ def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: self._wake.set() async def next_event(self) -> ServerEvent | _SubscriptionEnd: - """Return the next pending event, or the stream's end once drained. + """Return (but do not remove) the next pending event, or the stream's end once drained. + + The consumer removes the event with `consume()` only after its own + pre-return work (the `Subscription`'s `on_event` barrier) completed - + a cancellation landing mid-barrier leaves the event pending, so a + delivered event is never lost to the consumer's own timeout. A "local" end short-circuits the backlog: the consumer left the context, so buffered events must not read as live deliveries. The @@ -146,14 +198,20 @@ async def next_event(self) -> ServerEvent | _SubscriptionEnd: if self.end == "local": return self.end if self._pending: - event = next(iter(self._pending)) - del self._pending[event] - return event + return next(iter(self._pending)) if self.end is not None: return self.end await wake.wait() self._wake = anyio.Event() + def consume(self, event: ServerEvent) -> None: + """Remove a peeked event from the backlog; the consumer commits after its barrier ran.""" + self._pending.pop(event, None) + + +OnEvent = Callable[[ServerEvent], Awaitable[None]] +"""Per-event barrier awaited before a `Subscription` returns each event to its consumer.""" + class Subscription: """One open `subscriptions/listen` stream: an async iterator of typed events. @@ -163,8 +221,15 @@ class Subscription: graceful close never swallows deliveries that preceded it. """ - def __init__(self, route: ListenRoute, subscription_id: types.RequestId, honored: types.SubscriptionFilter): + def __init__( + self, + route: ListenRoute, + subscription_id: types.RequestId, + honored: types.SubscriptionFilter, + on_event: OnEvent | None = None, + ): self._route = route + self._on_event = on_event self.subscription_id = subscription_id """The listen request's JSON-RPC id - the value stamped into every frame's `_meta`.""" self.honored = honored @@ -189,6 +254,15 @@ async def __anext__(self) -> ServerEvent: # "graceful" (the server's deliberate close) and "local" (the # consumer already left the context) both end iteration cleanly. raise StopAsyncIteration + if self._on_event is not None: + # The barrier completes before the consumer can act on the event - + # `Client.listen` finishes response-cache eviction here, so an + # event-triggered refetch can never be served the pre-event entry. + # The event is still pending while the barrier runs: a cancellation + # (or a raising barrier) leaves it for the next `anext` instead of + # silently dropping a level trigger that would never re-fire. + await self._on_event(outcome) + self._route.consume(outcome) return outcome @@ -200,16 +274,26 @@ async def listen( prompts_list_changed: bool = False, resources_list_changed: bool = False, resource_subscriptions: Sequence[str] = (), + on_event: OnEvent | None = None, ) -> AsyncIterator[Subscription]: """Open one `subscriptions/listen` stream on `session` (2026-07-28 only). - The keyword arguments mirror the wire `SubscriptionFilter` field for - field; `resource_subscriptions` names exact resource URIs to watch for + The filter keyword arguments mirror the wire `SubscriptionFilter` field + for field; `resource_subscriptions` names exact resource URIs to watch for `ResourceUpdated` events. Entering sends the request and returns once the server's acknowledgment arrives (bounded by the session's read timeout, when one is set). Exiting ends the subscription. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id. + `on_event` is awaited before each event is returned from the iterator - + the seam for work that must complete before the consumer can react. + `Client.listen` finishes response-cache eviction here so an + event-triggered refetch cannot be served the pre-event entry; callers + opening a stream on a cached `Client`'s session directly should wire the + same barrier themselves (or use `Client.listen`). A raising barrier + surfaces from the iteration, with the event left pending for the next + `anext`. + Raises: ListenNotSupportedError: The negotiated protocol version predates 2026-07-28. MCPError: The server rejected the request, or the connection failed @@ -254,6 +338,10 @@ async def drive() -> None: except ValueError as error: # A caller-supplied raw request id collided with our minted # listen id: fail this subscription, not the whole session. + # The id belongs to the raw caller, so release the demux + # registration in this same slice - a registered route would + # consume the raw caller's own ack while this open unwinds. + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error))) return # The empty result is the spec's graceful close. Tolerant by design: @@ -277,7 +365,7 @@ async def drive() -> None: if route.error is not None: raise route.error raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged") - yield Subscription(route, request_id, route.honored) + yield Subscription(route, request_id, route.honored, on_event) finally: route.settle("local") driver_scope.cancel() diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py index 8e0cb551f..93a68d53b 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -52,6 +52,7 @@ ResourceUpdated, ServerEvent, ToolsListChanged, + event_matches, event_to_notification, ) @@ -155,21 +156,6 @@ def _honored_subset(requested: SubscriptionFilter) -> SubscriptionFilter: ) -def _event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: - """Whether `event` is within the stream's honored filter. - - `uris` is the honored `resource_subscriptions` as a set: matching runs on - every publish, and the wire filter may name many URIs. - """ - if isinstance(event, ToolsListChanged): - return honored.tools_list_changed is True - if isinstance(event, PromptsListChanged): - return honored.prompts_list_changed is True - if isinstance(event, ResourcesListChanged): - return honored.resources_list_changed is True - return event.uri in uris - - class ListenHandler: """Serves `subscriptions/listen`: one call is one subscription stream. @@ -218,7 +204,7 @@ async def __call__( send, recv = anyio.create_memory_object_stream[ServerEvent](self._max_buffered_events) def deliver(event: ServerEvent) -> None: - if _event_matches(honored, honored_uris, event): + if event_matches(honored, honored_uris, event): try: send.send_nowait(event) except anyio.ClosedResourceError: diff --git a/src/mcp/shared/direct_dispatcher.py b/src/mcp/shared/direct_dispatcher.py index 62c74b808..e17283afa 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,15 @@ from pydantic import ValidationError from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT, coerce_request_id +from mcp.shared.dispatcher import ( + CallOptions, + OnNotify, + OnNotifyIntercept, + OnRequest, + ProgressFnT, + coerce_request_id, + run_notify_intercept, +) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext @@ -106,6 +114,7 @@ def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: self._peer: DirectDispatcher | None = None self._on_request: OnRequest | None = None self._on_notify: OnNotify | None = None + self._on_notify_intercept: OnNotifyIntercept | None = None self._next_id = 0 self._in_flight_ids: set[RequestId] = set() self._ready = anyio.Event() @@ -158,6 +167,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -169,6 +179,7 @@ async def run( try: self._on_request = on_request self._on_notify = on_notify + self._on_notify_intercept = on_notify_intercept self._running = True self._ready.set() task_status.started() @@ -286,6 +297,8 @@ async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) # dropped, not raised back into the sender's call. logger.debug("dropped notification %r to closed DirectDispatcher", method) return + if run_notify_intercept(self._on_notify_intercept, method, params): + return assert self._on_notify is not None dctx = self._make_context() await self._on_notify(dctx, method, params) diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index 1cc8a87b2..514aa9aae 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -16,6 +16,7 @@ embedding a server in-process. """ +import logging from collections.abc import Awaitable, Callable, Mapping from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable @@ -26,16 +27,20 @@ from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext +logger = logging.getLogger(__name__) + __all__ = [ "CallOptions", "DispatchContext", "Dispatcher", "OnNotify", + "OnNotifyIntercept", "OnRequest", "Outbound", "ProgressFnT", "as_request_id", "coerce_request_id", + "run_notify_intercept", ] TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) @@ -223,6 +228,36 @@ async def progress(self, progress: float, total: float | None = None, message: s OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]] """Handler for inbound notifications: `(ctx, method, params)`.""" +OnNotifyIntercept = Callable[[str, Mapping[str, Any] | None], bool] +"""Synchronous receive-order intercept for inbound notifications: `(method, params) -> consumed`. + +Dispatchers invoke it for every inbound notification, in receive order, before +`on_notify` is scheduled. This is the seam for correlation state that must +advance in wire order relative to response resolution (the client demultiplexes +its listen streams here: an ack or event handled in a spawned task could lose +the race against the listen request's own result). Returning True consumes the +notification - `on_notify` never sees it. Runs on the receive path, so it must +not block; a raising intercept costs that one notification, not the connection. +Implementations honor it through `run_notify_intercept`, the one spelling of +that containment contract. +""" + + +def run_notify_intercept(intercept: OnNotifyIntercept | None, method: str, params: Mapping[str, Any] | None) -> bool: + """Invoke a notify intercept under the shared containment contract. + + The single spelling every dispatcher uses, so the substrates cannot drift: + a raising intercept costs that one interception (the frame passes through), + never the receive loop. + """ + if intercept is None: + return False + try: + return intercept(method, params) + except Exception: + logger.exception("notification intercept raised; passing %r through", method) + return False + class Dispatcher(Outbound, Protocol[TransportT_co]): """A duplex request/notification channel with call-return semantics. @@ -237,6 +272,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -244,7 +280,12 @@ async def run( Each inbound request is dispatched to `on_request` in its own task; the returned dict (or raised `MCPError`) is sent back as the response. - Inbound notifications go to `on_notify`. + Implementations MUST offer every inbound notification to + `on_notify_intercept` first, synchronously in receive order (via + `run_notify_intercept`), handing only the ones it does not consume to + `on_notify` - `ClientSession`'s subscription demux depends on this, + and a dispatcher that skips the intercept leaves `listen()` waiting + for an acknowledgment that is never recorded. `task_status.started()` is called once the dispatcher is ready to accept `send_request`/`notify` calls, so callers can use diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 673d01420..a62ea5f1d 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -44,10 +44,12 @@ DispatchContext, Dispatcher, OnNotify, + OnNotifyIntercept, OnRequest, ProgressFnT, as_request_id, coerce_request_id, + run_notify_intercept, ) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import ( @@ -294,6 +296,7 @@ def __init__( self._next_id = 0 self._pending: dict[RequestId, _Pending] = {} self._in_flight: dict[RequestId, _InFlight[TransportT]] = {} + self._on_notify_intercept: OnNotifyIntercept | None = None self._tg: anyio.abc.TaskGroup | None = None self._running = False self._closed = False @@ -463,6 +466,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -471,6 +475,7 @@ async def run( `task_status.started()` fires once `send_raw_request` is usable. Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted. """ + self._on_notify_intercept = on_notify_intercept try: # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land. async with self._write_stream: @@ -600,7 +605,10 @@ def _dispatch_notification( `notifications/cancelled` and `notifications/progress` are intercepted here (they correlate against the `_in_flight`/`_pending` tables this - layer owns) and still teed to `on_notify` afterwards. + layer owns) and still teed to `on_notify` afterwards. The caller's + `on_notify_intercept` then runs, synchronously in receive order, for + correlation state the layer above owns; only notifications it does not + consume are handed to the spawned `on_notify`. """ if msg.method == "notifications/cancelled": rid = cancelled_request_id_from_params(msg.params) @@ -627,6 +635,8 @@ def _dispatch_notification( ) case _: pass + if run_notify_intercept(self._on_notify_intercept, msg.method, msg.params): + return try: transport_ctx = self._transport_builder(metadata) except Exception: diff --git a/src/mcp/shared/subscriptions.py b/src/mcp/shared/subscriptions.py index 56f951ede..8a1fce6ed 100644 --- a/src/mcp/shared/subscriptions.py +++ b/src/mcp/shared/subscriptions.py @@ -13,6 +13,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -23,6 +24,7 @@ ResourceUpdatedNotification, ResourceUpdatedNotificationParams, ServerNotification, + SubscriptionFilter, ToolListChangedNotification, ) @@ -33,7 +35,8 @@ "ResourcesListChanged", "ServerEvent", "ToolsListChanged", - "event_from_notification", + "event_from_wire", + "event_matches", "event_to_notification", ] @@ -81,19 +84,43 @@ def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNot return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) -def event_from_notification(notification: ServerNotification) -> ServerEvent | None: - """The event a listen-stream notification announces (the client's direction). +_LIST_CHANGED_EVENTS: dict[str, ServerEvent] = { + "notifications/tools/list_changed": ToolsListChanged(), + "notifications/prompts/list_changed": PromptsListChanged(), + "notifications/resources/list_changed": ResourcesListChanged(), +} - Returns None for notification kinds that are not subscription events. + +def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None: + """The event a raw listen-stream frame announces (the client's direction). + + Reads the wire dict directly: the client demultiplexes on the dispatcher's + receive path, before the typed notification parse. Returns None for + non-event methods, and for a `resources/updated` frame with no string + `uri` (surface validation rejects those shapes downstream). + """ + if (event := _LIST_CHANGED_EVENTS.get(method)) is not None: + return event + if method == "notifications/resources/updated": + uri = (params or {}).get("uri") + if isinstance(uri, str): + return ResourceUpdated(uri=uri) + return None + + +def event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: + """Whether `event` is within a stream's honored filter. + + The one admission predicate both sides share: the server delivers only + what it acknowledged, and the client admits only what was acknowledged - + which is what bounds the client's backlog by the filter's width against + any peer, honest or not. `uris` is the honored `resource_subscriptions` + as a set: matching runs on every event, and the filter may name many URIs. """ - match notification: - case ToolListChangedNotification(): - return ToolsListChanged() - case PromptListChangedNotification(): - return PromptsListChanged() - case ResourceListChangedNotification(): - return ResourcesListChanged() - case ResourceUpdatedNotification(params=params): - return ResourceUpdated(uri=params.uri) - case _: - return None + if isinstance(event, ToolsListChanged): + return honored.tools_list_changed is True + if isinstance(event, PromptsListChanged): + return honored.prompts_list_changed is True + if isinstance(event, ResourcesListChanged): + return honored.resources_list_changed is True + return event.uri in uris diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py index 408810814..e22ec4015 100644 --- a/tests/client/test_send_request_mcp_name.py +++ b/tests/client/test_send_request_mcp_name.py @@ -22,7 +22,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.client.session import ClientSession -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value @@ -36,6 +36,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: diff --git a/tests/client/test_session.py b/tests/client/test_session.py index cd180102d..d9333d36c 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -39,9 +39,10 @@ from mcp.client import ClientRequestContext from mcp.client.client import Client from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession +from mcp.client.subscriptions import ToolsListChanged, listen from mcp.server import Server, ServerRequestContext from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair -from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY @@ -1342,6 +1343,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1429,6 +1431,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1487,6 +1490,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1812,16 +1816,18 @@ async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestPa @pytest.mark.anyio -async def test_a_late_ack_for_a_closed_driver_listen_is_dropped(): - """Ack consumption is namespaced, not timing-dependent: an ack for a - driver-minted listen id whose route is already unregistered (enter timed - out, context exited) is dropped instead of leaking to message_handler.""" +async def test_a_late_ack_for_a_closed_driver_listen_reaches_message_handler(): + """Ack consumption is keyed on the live route registry alone: once a + subscription closes its id is fully released, so a stray ack still + carrying it surfaces through message_handler like any other unowned + frame - nothing consumes it, and nothing remembers the id forever.""" seen: list[object] = [] follow_up = anyio.Event() async def handler(msg: object) -> None: seen.append(msg) - follow_up.set() + if len(seen) == 2: + follow_up.set() async with raw_client_session(message_handler=handler) as (session, to_client, _): _set_negotiated_version(session, "2026-07-28") @@ -1839,10 +1845,122 @@ async def handler(msg: object) -> None: ) ) ) - # A follow-up notification proves processing moved past the dropped ack. await to_client.send( SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed", params={})) ) with anyio.fail_after(5): await follow_up.wait() - assert [type(message).__name__ for message in seen] == ["ToolListChangedNotification"] + assert [type(message).__name__ for message in seen] == [ + "SubscriptionsAcknowledgedNotification", + "ToolListChangedNotification", + ] + + +@pytest.mark.anyio +async def test_a_graceful_result_does_not_outrun_the_events_that_preceded_it(): + """[ack, event, result] written back-to-back: the consumer drains the event + and the wire ack's filter survives, even with the message_handler tee parked + forever - route bookkeeping advances on the dispatcher's receive path in + wire order, not on the spawned tee's schedule (which a slow user handler + stalls arbitrarily). Regression: the result used to settle the route first, + dropping the event and clobbering the ack with a fabricated empty filter.""" + + async def parked_handler(message: object) -> None: + await anyio.sleep_forever() + + events: list[object] = [] + honored: list[types.SubscriptionFilter] = [] + async with raw_client_session(message_handler=parked_handler) as (session, to_client, from_client): + _set_negotiated_version(session, "2026-07-28") + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: # pragma: no branch + + async def consume() -> None: + async with listen(session, tools_list_changed=True) as sub: # pragma: no branch + honored.append(sub.honored) + events.extend([event async for event in sub]) + + tg.start_soon(consume) + request = await from_client.receive() + assert isinstance(request.message, JSONRPCRequest) + meta = {SUBSCRIPTION_ID_META_KEY: request.message.id} + for message in ( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/subscriptions/acknowledged", + params={"notifications": {"toolsListChanged": True}, "_meta": meta}, + ), + JSONRPCNotification( + jsonrpc="2.0", method="notifications/tools/list_changed", params={"_meta": meta} + ), + JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"_meta": meta}), + ): + await to_client.send(SessionMessage(message)) + assert honored == [types.SubscriptionFilter(tools_list_changed=True)] + assert events == [ToolsListChanged()] + + +def _intercept_only_session() -> ClientSession: + """A never-entered session whose intercept can be driven directly (it is synchronous).""" + dispatcher, _peer = create_direct_dispatcher_pair() + return ClientSession(dispatcher=dispatcher) + + +def test_intercept_settles_only_the_named_listen_route_on_cancelled(): + """SDK-defined demux contract: a server-sent cancel settles exactly the + listen route it names, and is never consumed (the session's v1-parity + swallow of cancelled happens downstream either way).""" + session = _intercept_only_session() + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + assert intercept("notifications/cancelled", {"requestId": "unrelated"}) is False + assert route.end is None + assert intercept("notifications/cancelled", {"requestId": "listen-1"}) is False + assert route.end == "lost" + + +def test_intercept_ignores_frames_without_a_route_or_with_broken_meta(): + """SDK-defined demux contract: frames that correlate to no live route - + no open subscriptions, non-mapping `_meta`, an unknown subscription id, or + a malformed event shape - flow through to the normal notification path.""" + session = _intercept_only_session() + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + # Fast path: no open subscriptions, nothing to correlate. + assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}) is False + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + route.set_acked(types.SubscriptionFilter(tools_list_changed=True)) + # A params-less frame carries no meta at all. + assert intercept("notifications/tools/list_changed", None) is False + # A frame whose `_meta` is not a mapping (constructible on pre-2026 wires) correlates to nothing. + assert intercept("notifications/tools/list_changed", {"_meta": "oops"}) is False + # A stamped frame for an id with no route flows through untouched. + assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "other"}}) is False + # A resources/updated frame with a non-string uri is not an event (surface validation owns it). + meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} + assert intercept("notifications/resources/updated", {"uri": 7, **meta}) is False + assert route._pending == {} # pyright: ignore[reportPrivateUsage] + + +def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones(): + """SDK-defined demux contract: a well-formed ack for a live route is driver + state (consumed, never surfaced); a malformed one is left to the spawned + path's validation warning; events deliver but still tee.""" + session = _intercept_only_session() + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} + # A malformed ack is not consumed: the spawned path's validation warning owns it. + assert intercept("notifications/subscriptions/acknowledged", {"notifications": ["nope"], **meta}) is False + assert route.honored is None + # So is an ack missing the required `notifications` field entirely - it must + # not be read as an (all-refusing) empty filter. + assert intercept("notifications/subscriptions/acknowledged", dict(meta)) is False + assert route.honored is None + assert ( + intercept("notifications/subscriptions/acknowledged", {"notifications": {"toolsListChanged": True}, **meta}) + is True + ) + assert route.honored == types.SubscriptionFilter(tools_list_changed=True) + # Events are delivered but never consumed - they still tee to message_handler. + assert intercept("notifications/tools/list_changed", meta) is False + assert list(route._pending) == [ToolsListChanged()] # pyright: ignore[reportPrivateUsage] diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py index 21cf2fa69..94ebd7946 100644 --- a/tests/client/test_session_claims.py +++ b/tests/client/test_session_claims.py @@ -28,7 +28,7 @@ from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult from mcp.client.session import ClientSession, _CallToolResultAdapter -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest _TASKS_EXT = "com.example/tasks" _AD_ONLY_EXT = "com.example/flags" @@ -75,6 +75,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index aae2f4e06..f923a258d 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -29,10 +29,16 @@ from mcp_types.version import LATEST_MODERN_VERSION from starlette.types import Receive, Scope, Send -from mcp.client.streamable_http import streamable_http_client +from mcp.client.streamable_http import ( + MAX_RECONNECTION_ATTEMPTS, + RequestContext, + StreamableHTTPTransport, + streamable_http_client, +) from mcp.server import Server from mcp.server._streamable_http_modern import handle_modern_request from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent +from mcp.shared._context_streams import ContextSendStream, create_context_streams from mcp.shared.dispatcher import CallOptions, DispatchContext from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher @@ -625,3 +631,55 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(reply.message, JSONRPCError) assert reply.message.id == "listen-1" assert reply.message.error.code == CONNECTION_CLOSED + + +def _abandoned_request_context( + http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception] +) -> RequestContext: + return RequestContext( + client=http, + session_id=None, + session_message=SessionMessage( + JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}) + ), + metadata=None, + read_stream_writer=send, + ) + + +@pytest.mark.anyio +async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None: + """The sibling of the non-resumable drop: an id-bearing stream whose + reconnection budget runs out also resolves the waiter - the no-silent-hang + guarantee is unconditional, not conditional on the server never stamping + event ids.""" + transport = StreamableHTTPTransport("http://test/mcp") + send, receive = create_context_streams[SessionMessage | Exception](1) + async with httpx.AsyncClient() as http: + with anyio.fail_after(5): + await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage] + _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS + ) + reply = await receive.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == CONNECTION_CLOSED + send.close() + receive.close() + + +@pytest.mark.anyio +async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None: + """Teardown race: the session can close the read stream's receive end while + a request's SSE stream is still dying; the resolution write is best-effort + (nobody is waiting) and must not crash the transport task group.""" + transport = StreamableHTTPTransport("http://test/mcp") + send, receive = create_context_streams[SessionMessage | Exception](1) + receive.close() + async with httpx.AsyncClient() as http: + with anyio.fail_after(5): + await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage] + _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS + ) + send.close() diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py index 2339b11d9..810664fbb 100644 --- a/tests/client/test_subscriptions.py +++ b/tests/client/test_subscriptions.py @@ -19,9 +19,11 @@ from mcp.client.session import ClientSession from mcp.client.subscriptions import ( ListenNotSupportedError, + ListenRoute, PromptsListChanged, ResourcesListChanged, ResourceUpdated, + ServerEvent, Subscription, SubscriptionLost, ToolsListChanged, @@ -498,6 +500,173 @@ async def test_a_bare_string_for_resource_subscriptions_is_rejected(): await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType] +def test_the_route_admits_only_honored_events_and_only_while_live(): + """Admission control at the route: nothing before the ack; after it, kind + events match the honored flags exactly, while a ResourceUpdated is admitted + whenever URI subscriptions were honored at all (the spec lets the stamped + URI be a sub-resource of a subscribed one); nothing once the stream ended.""" + route = ListenRoute() + route.deliver(ToolsListChanged()) # before the ack nothing is admissible + assert route._pending == {} # pyright: ignore[reportPrivateUsage] + route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"])) + route.deliver(PromptsListChanged()) # kind not honored + route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: admitted + route.deliver(ResourceUpdated(uri="note://todo")) + route.deliver(ToolsListChanged()) + route.deliver(ToolsListChanged()) # duplicate pending consumption collapses + assert list(route._pending) == [ # pyright: ignore[reportPrivateUsage] + ResourceUpdated(uri="note://todo/draft"), + ResourceUpdated(uri="note://todo"), + ToolsListChanged(), + ] + route.settle("graceful") + route.deliver(ResourceUpdated(uri="note://todo")) # post-close noise is refused + assert len(route._pending) == 3 # pyright: ignore[reportPrivateUsage] + + +def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory(): + """`_MAX_PENDING_EVENTS` backstops the one backlog admission cannot bound: + URI admission is deliberately loose (sub-resources), so a flooding peer + settles the route lost - re-listen and refetch - instead of growing the + pending map without bound.""" + route = ListenRoute() + route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"])) + for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage] + route.deliver(ResourceUpdated(uri=f"note://todo/{n}")) + assert route.end is None + route.deliver(ResourceUpdated(uri="note://todo/one-too-many")) + assert route.end == "lost" + assert route.error is not None + assert "backlog" in route.error.error.message + # The overflowing event was not queued; the drained backlog stays at the cap. + assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage] + + +async def test_a_cancelled_on_event_barrier_does_not_lose_the_event(): + """The event stays pending while the barrier runs: a consumer-side timeout + cancelling `anext` mid-barrier leaves the level trigger queued, and the + next `anext` re-runs the (idempotent) barrier and returns it.""" + bus = InMemorySubscriptionBus() + entered = anyio.Event() + release = anyio.Event() + + async def parked_barrier(event: ServerEvent) -> None: + entered.set() + await release.wait() + + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with listen( + client.session, tools_list_changed=True, on_event=parked_barrier + ) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + async with anyio.create_task_group() as tg: + cancel_scope = anyio.CancelScope() + + async def first_attempt() -> None: + with cancel_scope: + await anext(sub) + raise AssertionError("must be cancelled mid-barrier") # pragma: no cover + + tg.start_soon(first_attempt) + await entered.wait() + cancel_scope.cancel() + release.set() + assert await anext(sub) == ToolsListChanged() + + +async def test_events_outside_the_honored_filter_are_never_delivered(): + """A server that violates its acknowledged filter cannot reach the consumer + (or grow the backlog): the route admits only honored events.""" + proceed = anyio.Event() + + async def overreaching_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + meta = await _ack(ctx, params.notifications) # honors exactly what was requested: tools only + await ctx.session.send_notification( + types.ResourceUpdatedNotification( + params=types.ResourceUpdatedNotificationParams(uri="note://uninvited", _meta=meta) + ), + related_request_id=ctx.request_id, + ) + await ctx.session.send_notification( + types.ToolListChangedNotification(params=types.NotificationParams(_meta=meta)), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta=meta) + + server = Server("subs", on_subscriptions_listen=overreaching_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert await anext(sub) == ToolsListChanged() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_the_on_event_barrier_completes_before_each_event_is_returned(): + """`on_event` is awaited between the route handing over an event and the + iterator returning it - the seam consumers use for must-happen-before work + (the Client wires cache eviction here).""" + bus = InMemorySubscriptionBus() + order: list[str] = [] + + async def barrier(event: ServerEvent) -> None: + order.append(f"barrier:{type(event).__name__}") + + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with listen(client.session, tools_list_changed=True, on_event=barrier) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + event = await anext(sub) + order.append(f"returned:{type(event).__name__}") + assert order == ["barrier:ToolsListChanged", "returned:ToolsListChanged"] + + +async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists(): + """`Client.listen` wires its response-cache evictor as the event barrier; + with caching disabled there is no barrier to pay for.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as cached_client: + with anyio.fail_after(5): + async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage] + async with Client(_bus_server(bus), cache=False) as uncached_client: + with anyio.fail_after(5): + async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub._on_event is None # pyright: ignore[reportPrivateUsage] + + +async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The barrier evicts through the same notification mapping as the + message_handler wrapper, and a raising store costs a log line, not the + event delivery.""" + client = Client(_bus_server(InMemorySubscriptionBus())) + cache = client._response_cache # pyright: ignore[reportPrivateUsage] + assert cache is not None + evicted: list[types.ServerNotification] = [] + + async def record(notification: types.ServerNotification) -> None: + evicted.append(notification) + + monkeypatch.setattr(cache, "evict_for_notification", record) + await client._evict_for_listen_event(ResourceUpdated(uri="note://x")) # pyright: ignore[reportPrivateUsage] + assert isinstance(evicted[0], types.ResourceUpdatedNotification) + assert evicted[0].params.uri == "note://x" + + async def broken(notification: types.ServerNotification) -> None: + raise RuntimeError("store down") + + monkeypatch.setattr(cache, "evict_for_notification", broken) + # Contained: a cache fault must not block delivery. + await client._evict_for_listen_event(ToolsListChanged()) # pyright: ignore[reportPrivateUsage] + + async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -530,6 +699,9 @@ async def raw_listen() -> None: with pytest.raises(MCPError) as exc_info: await client.listen(tools_list_changed=True).__aenter__() assert "already in flight" in exc_info.value.error.message + # The failed open released the colliding id's demux registration: + # only the raw caller may see frames stamped with it. + assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage] raw_scope.cancel() # The session is intact: the next listen mints a fresh id and opens. async with client.listen(tools_list_changed=True) as sub: # pragma: no branch diff --git a/tests/shared/test_dispatcher.py b/tests/shared/test_dispatcher.py index 03ef27c8d..c57b4f92d 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -25,7 +25,7 @@ from mcp.shared._compat import resync_tracer from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair -from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest, Outbound +from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, OnRequest, Outbound from mcp.shared.exceptions import MCPError from mcp.shared.transport_context import TransportContext @@ -66,6 +66,7 @@ async def running_pair( server_on_notify: OnNotify | None = None, client_on_request: OnRequest | None = None, client_on_notify: OnNotify | None = None, + client_on_notify_intercept: OnNotifyIntercept | None = None, can_send_request: bool = True, ) -> AsyncIterator[tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Recorder, Recorder]]: """Yield `(client, server, client_recorder, server_recorder)` with both `run()` loops live.""" @@ -75,7 +76,9 @@ async def running_pair( s_req, s_notify = echo_handlers(server_rec) try: async with anyio.create_task_group() as tg: - await tg.start(client.run, client_on_request or c_req, client_on_notify or c_notify) + await tg.start( + client.run, client_on_request or c_req, client_on_notify or c_notify, client_on_notify_intercept + ) await tg.start(server.run, server_on_request or s_req, server_on_notify or s_notify) try: yield client, server, client_rec, server_rec @@ -509,6 +512,71 @@ async def first() -> None: assert await client.send_raw_request("again", None, {"request_id": "7"}) == {} +@pytest.mark.anyio +async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory): + """The intercept observes each inbound notification before `on_notify` is + scheduled; a frame it consumes never reaches `on_notify`, the rest do.""" + intercepted: list[str] = [] + + def intercept(method: str, params: Mapping[str, Any] | None) -> bool: + intercepted.append(method) + return method == "notifications/consumed" + + async with running_pair(pair_factory, client_on_notify_intercept=intercept) as (_client, server, crec, _srec): + with anyio.fail_after(5): + await server.notify("notifications/consumed", None) + await server.notify("notifications/passed", None) + await crec.notified.wait() + assert intercepted == ["notifications/consumed", "notifications/passed"] + assert [method for method, _ in crec.notifications] == ["notifications/passed"] + + +@pytest.mark.anyio +async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory): + """Notifications written before a response are intercepted before that + response resolves: correlation state fed through the intercept is ordered + against `send_raw_request`'s return, whatever the spawned handlers do.""" + seen: list[str] = [] + + def intercept(method: str, params: Mapping[str, Any] | None) -> bool: + seen.append(method) + return False + + async def notify_then_answer( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + await ctx.notify("notifications/first", None) + await ctx.notify("notifications/second", None) + return {} + + async with running_pair( + pair_factory, server_on_request=notify_then_answer, client_on_notify_intercept=intercept + ) as (client, *_): + with anyio.fail_after(5): + await client.send_raw_request("burst", None) + assert seen == ["notifications/first", "notifications/second"] + + +@pytest.mark.anyio +async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory): + """An intercept exception costs only that interception: the notification + still reaches `on_notify` and the receive loop survives.""" + + def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool: + raise RuntimeError("intercept exploded") + + async with running_pair(pair_factory, client_on_notify_intercept=broken_intercept) as ( + _client, + server, + crec, + _srec, + ): + with anyio.fail_after(5): + await server.notify("notifications/survives", None) + await crec.notified.wait() + assert [method for method, _ in crec.notifications] == ["notifications/survives"] + + if TYPE_CHECKING: _d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) _o: Outbound = _d From d7f83bcccc38f04a9e15996d78b262f9746a1542 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:33:28 +0000 Subject: [PATCH 5/8] Trim comments and docstrings in the listen driver changes Comment-only pass over the branch's additions: keep the non-inferable invariants and motivations (receive-order rationale, sub-resource admission, the wake-snapshot race, peek/commit semantics) at one to three lines each, tighten docstrings to Google style with Raises sections kept, and drop narration the code already states. No code changes. --- src/mcp/client/client.py | 30 +-- src/mcp/client/session.py | 49 ++--- src/mcp/client/streamable_http.py | 19 +- src/mcp/client/subscriptions.py | 174 +++++------------- src/mcp/server/subscriptions.py | 4 +- src/mcp/shared/dispatcher.py | 32 +--- src/mcp/shared/jsonrpc_dispatcher.py | 5 +- src/mcp/shared/subscriptions.py | 36 +--- tests/client/test_session.py | 39 ++-- tests/client/test_streamable_http.py | 15 +- tests/client/test_subscriptions.py | 116 ++++-------- tests/docs_src/test_subscriptions.py | 6 +- .../lowlevel/test_subscriptions.py | 6 +- .../mcpserver/test_subscriptions.py | 9 +- tests/shared/test_dispatcher.py | 10 +- 15 files changed, 147 insertions(+), 403 deletions(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 62d581eb7..ab4813840 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -675,28 +675,19 @@ def listen( ) -> AbstractAsyncContextManager[Subscription]: """Open a `subscriptions/listen` stream of typed change events (2026-07-28 only). - The keyword arguments mirror the wire `SubscriptionFilter` field for - field; `resource_subscriptions` names exact resource URIs to watch. - Entering waits for the server's acknowledgment - the subset it agreed - to deliver is `sub.honored` - then the handle yields events: + Keyword args mirror the wire `SubscriptionFilter`; entering waits for the ack (honored subset: `sub.honored`): async with client.listen(tools_list_changed=True) as sub: async for event in sub: tools = await client.list_tools() # refetch on change - A graceful server close ends the loop; an abrupt drop raises - `SubscriptionLost`. Either way the stream is gone and there is no - replay: a client that keeps watching re-listens and refetches. - Exiting the context ends the subscription. Multiple subscriptions may - be open concurrently. + A graceful close ends the loop; an abrupt drop raises `SubscriptionLost`. No replay: re-listen and refetch. Raises: - ListenNotSupportedError: The negotiated protocol version predates - 2026-07-28 (use `subscribe_resource` and `message_handler` there). - MCPError: The server rejected the request, or the connection - failed before the acknowledgment arrived. + ListenNotSupportedError: The negotiated protocol version predates 2026-07-28. + MCPError: The server rejected the request or the connection failed first. SubscriptionLost: The stream ended before it was acknowledged. - TimeoutError: The session's read timeout elapsed before the acknowledgment. + TimeoutError: The read timeout elapsed before the acknowledgment. """ return _listen( self.session, @@ -710,14 +701,9 @@ def listen( async def _evict_for_listen_event(self, event: ServerEvent) -> None: """Finish response-cache eviction before a listen consumer can refetch. - The eviction wrapper on message_handler runs on a spawned path; the - consumer's iterator would otherwise wake first, refetch through a - still-warm entry, and - events being deduplicated level triggers - - never get a second wake to correct it. The event's notification still - tees to that wrapper, so the same eviction fires twice; that is - deliberate (eviction is idempotent, and the tee-path one covers - non-iterating consumers sharing this cache). Same containment boundary - as `_evicting_message_handler`: a cache fault must not block delivery. + Without it the iterator wakes first and refetches a still-warm entry, with no + corrective wake (events are deduplicated level triggers). The tee path repeats + the eviction; deliberate: idempotent, and it covers non-iterating consumers. """ cache = self._response_cache assert cache is not None # installed as the event barrier only when a cache exists diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index a9861a570..097ade1c9 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -363,12 +363,7 @@ def __init__( self._negotiated_version: str | None = None self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp self._task_group: anyio.abc.TaskGroup | None = None - # subscriptions/listen demux routes, keyed by the listen request's id - # (verbatim-typed: a plain dict already keeps 1 and "1" distinct). - # Fed by `_intercept_notification` on the dispatcher's receive path; - # membership alone decides ack consumption, so a closed subscription - # releases its id completely - raw escape-hatch listens (never - # registered here) keep receiving their acks via message_handler. + # subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered) self._listen_routes: dict[RequestId, ListenRoute] = {} if dispatcher is not None: if read_stream is not None or write_stream is not None: @@ -1257,43 +1252,25 @@ def _unregister_listen_route(self, request_id: RequestId) -> None: self._listen_routes.pop(request_id, None) def _settle_listen_routes_closed(self) -> None: - """End every open subscription as lost: the session is gone. - - Without this, a consumer iterating in a sibling task would park forever - - the driver task dies by cancellation and can never settle its route. - """ + """Settle all open listen routes as lost on session exit; cancelled driver tasks cannot.""" closed = MCPError(code=CONNECTION_CLOSED, message="Connection closed") for route in self._listen_routes.values(): route.settle("lost", error=closed) self._listen_routes.clear() def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) -> bool: - """Wire-order listen demux, run by the dispatcher on its receive path. - - Route bookkeeping must advance in receive order relative to the listen - request's own result: the result resolves synchronously on this same - path, so an ack or event handled on the spawned `_on_notify` path could - lose the race and be dropped after the stream settled - a graceful - close swallowing the events that preceded it. Only the synchronous - bookkeeping happens here; the user-facing tee (message_handler, - logging) stays on the spawned path. - - Returns True to consume the frame: an ack for a live driver route is - driver state, never surfaced. Raw escape-hatch listens have no route - registered and keep observing their acks via message_handler - as does - a stray ack for an already-closed driver id, whose registration is gone. - - The `as_request_id` guard is not a tripwire: this reads raw wire - dicts, where a non-id (even unhashable) `_meta` value is - constructible and would fail the dict lookup. + """Wire-order listen demux, run synchronously on the dispatcher's receive path. + + Bookkeeping must advance in receive order with the listen result (resolved on + this same path); the spawned `_on_notify` path would race it and drop events. + Returns True to consume the frame: a live route's ack is driver state, never surfaced. """ if not self._listen_routes: return False if method == "notifications/cancelled": request_id = cancelled_request_id_from_params(params) if request_id is not None and (listen_route := self._listen_routes.get(request_id)) is not None: - # A server-sent cancel naming one of our listen requests is - # that stream's teardown signal (the stream-transport spelling). + # a server-sent cancel naming a listen request is that stream's teardown signal listen_route.settle("lost") return False # _on_notify swallows every cancelled either way (v1 parity) if params is None: @@ -1301,20 +1278,18 @@ def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) meta = params.get("_meta") if not isinstance(meta, Mapping): return False + # as_request_id is not a tripwire: raw wire _meta can carry a non-id (even unhashable) value subscription_id = as_request_id(cast("Mapping[str, Any]", meta).get(SUBSCRIPTION_ID_META_KEY)) if subscription_id is None or (listen_route := self._listen_routes.get(subscription_id)) is None: return False if method == "notifications/subscriptions/acknowledged": raw_filter = params.get("notifications") if raw_filter is None: - # The wire shape requires `notifications`; a frame without it is - # malformed, not an empty filter - leave it to the spawned - # path's validation warning rather than fabricating honored={}. + # malformed, not an empty filter: leave it to the spawned path's validation warning return False try: honored = types.SubscriptionFilter.model_validate(raw_filter) except ValidationError: - # Malformed ack: leave it to the spawned path's validation warning. return False listen_route.set_acked(honored) return True @@ -1356,9 +1331,7 @@ async def _on_notify( logger.warning("Failed to validate notification: %s", method, exc_info=True) return if isinstance(notification, types.CancelledNotification): - # Never surfaced to message_handler (v1 parity): the dispatcher - # already applied any in-flight cancellation, and a cancel naming - # one of our listen streams was settled by the wire-order intercept. + # Never surfaced (v1 parity): the dispatcher already applied it; listen cancels settled by the intercept. return try: if isinstance(notification, types.LoggingMessageNotification): diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 09040ccc2..4929bdf7f 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -443,10 +443,8 @@ async def _handle_sse_response( logger.info("SSE stream disconnected, reconnecting...") await self._handle_reconnection(ctx, last_event_id, retry_interval_ms) else: - # Not resumable: the response can never arrive. Resolve the waiter - # instead of leaving the request pending for the session's lifetime - # (a listen stream's consumer would otherwise hang instead of - # learning the subscription is lost). + # Not resumable: resolve the waiter, else a listen stream's consumer + # would hang forever instead of learning the subscription is lost. await self._resolve_abandoned_request( ctx.read_stream_writer, original_request_id, "SSE stream ended without a response" ) @@ -456,9 +454,7 @@ async def _resolve_abandoned_request( ) -> None: """Resolve a request whose response can never arrive with a synthesized error. - Best-effort, like the dispatcher's own resolution writes: the read - stream closing first means the session is being torn down and nobody - is waiting on this request anymore. + Best-effort: a closed read stream means the session is tearing down. """ error_data = ErrorData(code=CONNECTION_CLOSED, message=message) error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) @@ -475,16 +471,13 @@ async def _handle_reconnection( attempt: int = 0, ) -> None: """Reconnect with Last-Event-ID to resume stream after server disconnect.""" - # Only requests reconnect: every caller reaches here from a request's - # response stream (`_handle_sse_response` asserts the same), so the - # original id to map responses onto is always present. + # Only requests reconnect: every caller arrives from a request's response stream. assert isinstance(ctx.session_message.message, JSONRPCRequest) original_request_id = ctx.session_message.message.id if attempt >= MAX_RECONNECTION_ATTEMPTS: - # Give up AND resolve: without a synthesized error the waiter is - # pending for the session's lifetime, and a request with no read - # timeout (a listen stream) would hang its caller forever. + # Resolve on give-up: a request with no read timeout (a listen + # stream) would otherwise hang its caller forever. logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") await self._resolve_abandoned_request( ctx.read_stream_writer, diff --git a/src/mcp/client/subscriptions.py b/src/mcp/client/subscriptions.py index 45e22590f..27283909b 100644 --- a/src/mcp/client/subscriptions.py +++ b/src/mcp/client/subscriptions.py @@ -1,25 +1,10 @@ """Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575). -On the 2026 wire a client opts in to server change notifications by sending -one `subscriptions/listen` request whose response IS the stream. This module -turns that into an async context manager: - - async with client.listen(tools_list_changed=True) as sub: - async for event in sub: - ... # ToolsListChanged() - go refetch - -Entering waits for the server's acknowledgment, so `sub.honored` (the subset -of the requested filter the server agreed to deliver) is always populated. -Iteration yields the same typed events the server publishes. The stream's two -endings are control flow: a graceful server close simply ends the loop, an -abrupt drop raises `SubscriptionLost`. Exiting the context ends the -subscription with the transport's own cancellation spelling (aborting the -request's stream over streamable HTTP, `notifications/cancelled` on stream -transports). There is no replay and no automatic re-listen: a client that -re-opens a subscription refetches what it depends on. - -`listen(session, ...)` is the composable helper for callers holding a bare -`ClientSession`; `Client.listen(...)` is the high-level spelling. +`listen()` opens the stream as an async context manager: entering waits for +the server's acknowledgment, iteration yields typed change events, a graceful +server close ends the loop, and an abrupt drop raises `SubscriptionLost`. +There is no replay and no automatic re-listen: a client that re-opens a +subscription refetches what it depends on. """ from __future__ import annotations @@ -64,24 +49,15 @@ """Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints.""" _MAX_PENDING_EVENTS = 1024 -"""Backstop on one route's unconsumed backlog, mirroring the server's `max_buffered_events`. - -Kind events are bounded by dedupe alone, but `ResourceUpdated` admission is -per-URI and the spec lets a server stamp sub-resources of a subscribed URI - -so distinct pending URIs are unbounded in principle. A peer that floods past -this cap costs the subscription (settled lost: re-listen and refetch), never -unbounded client memory. -""" +"""Backlog backstop: the spec allows sub-resource URIs, so distinct pending +`ResourceUpdated` events are unbounded; overflowing this cap settles the +subscription lost rather than growing client memory.""" _SubscriptionEnd = Literal["graceful", "lost", "local"] class ListenNotSupportedError(RuntimeError): - """`subscriptions/listen` requires a 2026-07-28 connection. - - On earlier protocol versions, subscribe with `subscribe_resource()` and - receive change notifications through the session's `message_handler`. - """ + """`subscriptions/listen` requires a 2026-07-28 connection.""" def __init__(self, negotiated_version: str | None) -> None: self.negotiated_version = negotiated_version @@ -93,29 +69,11 @@ def __init__(self, negotiated_version: str | None) -> None: class SubscriptionLost(RuntimeError): - """The subscription's stream ended without the server's graceful close. - - The transport dropped, or the server tore the stream down abruptly. There - is no replay: re-open with `listen()` and refetch what you depend on. - """ + """The stream ended without the server's graceful close; re-listen and refetch.""" class ListenRoute: - """Demux state for one listen stream, fed in wire order by the session. - - Package-internal (deliberately not in `__all__`): `ClientSession` - constructs it and feeds it from its notification intercept on the - dispatcher's receive path, so acks, events, and stream-teardown signals - land here in receive order - ordered against the listen request's own - result, which resolves on that same path. `Subscription` is the one - consumer. - - Everything here is synchronous on the event loop - the receive path must - never block on a slow consumer. Pending events deduplicate: every event - kind is a level trigger, so the backlog is bounded by the distinct - admissible events, with `_MAX_PENDING_EVENTS` as the backstop for the - URI class that admission deliberately leaves open. - """ + """Package-internal demux state for one listen stream, fed synchronously in receive order by the session.""" def __init__(self) -> None: self.honored: types.SubscriptionFilter | None = None @@ -127,25 +85,17 @@ def __init__(self) -> None: self._wake = anyio.Event() def set_acked(self, honored: types.SubscriptionFilter) -> None: - """Record the acknowledged filter; the first ack wins, later ones are no-ops.""" + """Record the acknowledged filter; the first ack wins.""" if not self.acked.is_set(): self.honored = honored self._honored_uris = frozenset(honored.resource_subscriptions or ()) self.acked.set() def deliver(self, event: ServerEvent) -> None: - """Queue `event` for the consumer; duplicates of pending events are dropped. - - Only events within the honored filter are admitted, so a peer that - violates its own acknowledgment cannot reach the consumer. Kind events - match the honored flags exactly; a `ResourceUpdated` is admitted - whenever URI subscriptions were honored at all, because the spec lets - the stamped URI be a sub-resource of a subscribed one (schema: - ResourceUpdatedNotificationParams.uri). Before the ack there is no - honored filter and nothing is admissible (the spec makes the ack the - stream's first frame); after the stream has ended a frame can only be - post-close noise. `_MAX_PENDING_EVENTS` backstops the one backlog - admission cannot bound. + """Queue an event within the honored filter, deduplicated against the backlog. + + Any `ResourceUpdated` is admitted once URI subscriptions were honored at + all: the spec allows the stamped URI to be a sub-resource of a subscribed one. """ if self.end is not None or self.honored is None: return @@ -168,10 +118,7 @@ def deliver(self, event: ServerEvent) -> None: self._wake.set() def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: - """Record the stream's end; the first reason wins. - - Also wakes the ack waiter so a pre-ack failure surfaces immediately. - """ + """Record the stream's end; the first reason wins and wakes both waiters.""" if self.end is None: self.end = end self.error = error @@ -179,21 +126,13 @@ def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: self._wake.set() async def next_event(self) -> ServerEvent | _SubscriptionEnd: - """Return (but do not remove) the next pending event, or the stream's end once drained. - - The consumer removes the event with `consume()` only after its own - pre-return work (the `Subscription`'s `on_event` barrier) completed - - a cancellation landing mid-barrier leaves the event pending, so a - delivered event is never lost to the consumer's own timeout. + """Peek the next pending event, or the stream's end once the backlog drains. - A "local" end short-circuits the backlog: the consumer left the - context, so buffered events must not read as live deliveries. The - other endings drain first - a graceful close never swallows events - that preceded it. + A "local" end short-circuits the backlog; the other endings drain it first, + so a graceful close never swallows events that preceded it. """ while True: - # Snapshot the wake event BEFORE checking state: a deliver landing - # after the checks sets this same object, so the wait cannot miss it. + # Snapshot the wake event before checking state so a deliver landing after the checks cannot be missed. wake = self._wake if self.end == "local": return self.end @@ -205,7 +144,7 @@ async def next_event(self) -> ServerEvent | _SubscriptionEnd: self._wake = anyio.Event() def consume(self, event: ServerEvent) -> None: - """Remove a peeked event from the backlog; the consumer commits after its barrier ran.""" + """Remove a peeked event from the backlog.""" self._pending.pop(event, None) @@ -217,8 +156,6 @@ class Subscription: """One open `subscriptions/listen` stream: an async iterator of typed events. Produced by `listen()` / `Client.listen()`, not constructed directly. - Buffered events are served before the stream's end is reported, so a - graceful close never swallows deliveries that preceded it. """ def __init__( @@ -231,9 +168,9 @@ def __init__( self._route = route self._on_event = on_event self.subscription_id = subscription_id - """The listen request's JSON-RPC id - the value stamped into every frame's `_meta`.""" + """The listen request's JSON-RPC id, stamped into every frame's `_meta`.""" self.honored = honored - """The subset of the requested filter the server agreed to deliver (the acknowledgment).""" + """The subset of the requested filter the server agreed to deliver.""" def __aiter__(self) -> Subscription: return self @@ -242,7 +179,7 @@ async def __anext__(self) -> ServerEvent: """Yield the next change event; the loop ends when the stream does. Raises: - SubscriptionLost: The stream dropped without the server's graceful close. + SubscriptionLost: the stream dropped without the server's graceful close. """ outcome = await self._route.next_event() if isinstance(outcome, str): @@ -251,16 +188,10 @@ async def __anext__(self) -> ServerEvent: f"subscription {self.subscription_id!r} ended without the server's graceful close;" " re-listen and refetch" ) from self._route.error - # "graceful" (the server's deliberate close) and "local" (the - # consumer already left the context) both end iteration cleanly. raise StopAsyncIteration if self._on_event is not None: - # The barrier completes before the consumer can act on the event - - # `Client.listen` finishes response-cache eviction here, so an - # event-triggered refetch can never be served the pre-event entry. - # The event is still pending while the barrier runs: a cancellation - # (or a raising barrier) leaves it for the next `anext` instead of - # silently dropping a level trigger that would never re-fire. + # The event stays pending while the barrier runs: a cancellation or a + # raising barrier leaves it for the next anext instead of dropping it. await self._on_event(outcome) self._route.consume(outcome) return outcome @@ -278,28 +209,16 @@ async def listen( ) -> AsyncIterator[Subscription]: """Open one `subscriptions/listen` stream on `session` (2026-07-28 only). - The filter keyword arguments mirror the wire `SubscriptionFilter` field - for field; `resource_subscriptions` names exact resource URIs to watch for - `ResourceUpdated` events. Entering sends the request and returns once the - server's acknowledgment arrives (bounded by the session's read timeout, - when one is set). Exiting ends the subscription. Multiple subscriptions - may be open concurrently; each demultiplexes by its own subscription id. - - `on_event` is awaited before each event is returned from the iterator - - the seam for work that must complete before the consumer can react. - `Client.listen` finishes response-cache eviction here so an - event-triggered refetch cannot be served the pre-event entry; callers - opening a stream on a cached `Client`'s session directly should wire the - same barrier themselves (or use `Client.listen`). A raising barrier - surfaces from the iteration, with the event left pending for the next - `anext`. + Entering sends the request and returns once the server's acknowledgment + arrives; exiting ends the subscription. `on_event` is awaited before each + event is returned - the seam `Client.listen` uses to finish cache eviction + before the consumer can refetch. Raises: - ListenNotSupportedError: The negotiated protocol version predates 2026-07-28. - MCPError: The server rejected the request, or the connection failed - before the acknowledgment arrived. - SubscriptionLost: The stream ended before it was acknowledged. - TimeoutError: The session's read timeout elapsed before the acknowledgment. + ListenNotSupportedError: negotiated version predates 2026-07-28. + MCPError: the server rejected the request, or the connection failed pre-ack. + SubscriptionLost: the stream ended before it was acknowledged. + TimeoutError: the session's read timeout elapsed before the acknowledgment. """ if session.protocol_version not in MODERN_PROTOCOL_VERSIONS: raise ListenNotSupportedError(session.protocol_version) @@ -325,8 +244,7 @@ async def listen( driver_scope = anyio.CancelScope() async def drive() -> None: - # The listen request deliberately carries no result timeout: its - # response arrives when the stream ends, however long that takes. + # Deliberately no result timeout: the response arrives when the stream ends. with driver_scope: try: await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] @@ -336,32 +254,24 @@ async def drive() -> None: route.settle("lost", error=error) return except ValueError as error: - # A caller-supplied raw request id collided with our minted - # listen id: fail this subscription, not the whole session. - # The id belongs to the raw caller, so release the demux - # registration in this same slice - a registered route would - # consume the raw caller's own ack while this open unwinds. + # A raw request id collided with our minted listen id: fail this subscription + # and release the route in this same slice, so it cannot consume the raw caller's ack. session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error))) return - # The empty result is the spec's graceful close. Tolerant by design: - # receiving it IS the signal, whatever its body. A result with no - # prior ack opens the subscription already closed. + # A result, whatever its body, is the spec's graceful close; with no prior ack + # it opens the subscription already closed. route.set_acked(types.SubscriptionFilter()) route.settle("graceful") - # Register the demux route before the request is written so the ack can - # never race it; from here the finally owns cleanup, so a failing spawn - # cannot leak the registration. + # Register the demux route before the request is written so the ack cannot race it. route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage] try: task_group.start_soon(drive) with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage] await route.acked.wait() if route.honored is None: - # No ack means no subscription: raise, don't degrade. (A graceful - # result with no ack acked an empty filter in drive(), so honored - # is None here only on the failure paths.) + # Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive(). if route.error is not None: raise route.error raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged") diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py index 93a68d53b..6b0b3d49b 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -13,9 +13,7 @@ `MCPServer` registers one automatically; lowlevel `Server` users pass an instance as `on_subscriptions_listen=`. -The event vocabulary (the four `ServerEvent` dataclasses and the -`_meta` subscription-id key) is defined in `mcp.shared.subscriptions`, -shared with the client driver, and re-exported here. +The event vocabulary lives in `mcp.shared.subscriptions`, shared with the client driver, and is re-exported here. Per the spec, the handler acknowledges first (the ack is the first frame on the stream), tags every frame with the listen request's JSON-RPC id under diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index 514aa9aae..f109638f2 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -47,11 +47,7 @@ def as_request_id(value: object) -> RequestId | None: - """Narrow an untyped wire value to a `RequestId`, or None. - - Rejects bool explicitly: bool subclasses int, so True would alias - request id 1 in every correlation table keyed by id. - """ + """Narrow an untyped wire value to a `RequestId`, or None; rejects bool (True would alias request id 1).""" if isinstance(value, str | int) and not isinstance(value, bool): return value return None @@ -231,25 +227,14 @@ async def progress(self, progress: float, total: float | None = None, message: s OnNotifyIntercept = Callable[[str, Mapping[str, Any] | None], bool] """Synchronous receive-order intercept for inbound notifications: `(method, params) -> consumed`. -Dispatchers invoke it for every inbound notification, in receive order, before -`on_notify` is scheduled. This is the seam for correlation state that must -advance in wire order relative to response resolution (the client demultiplexes -its listen streams here: an ack or event handled in a spawned task could lose -the race against the listen request's own result). Returning True consumes the -notification - `on_notify` never sees it. Runs on the receive path, so it must -not block; a raising intercept costs that one notification, not the connection. -Implementations honor it through `run_notify_intercept`, the one spelling of -that containment contract. +Runs before `on_notify` is scheduled so correlation state advances in wire order +relative to response resolution (the client's listen demux depends on this). +Returning True consumes the notification. Must not block the receive path. """ def run_notify_intercept(intercept: OnNotifyIntercept | None, method: str, params: Mapping[str, Any] | None) -> bool: - """Invoke a notify intercept under the shared containment contract. - - The single spelling every dispatcher uses, so the substrates cannot drift: - a raising intercept costs that one interception (the frame passes through), - never the receive loop. - """ + """Invoke `intercept`, containing a raise to that one notification (never the receive loop).""" if intercept is None: return False try: @@ -281,11 +266,8 @@ async def run( Each inbound request is dispatched to `on_request` in its own task; the returned dict (or raised `MCPError`) is sent back as the response. Implementations MUST offer every inbound notification to - `on_notify_intercept` first, synchronously in receive order (via - `run_notify_intercept`), handing only the ones it does not consume to - `on_notify` - `ClientSession`'s subscription demux depends on this, - and a dispatcher that skips the intercept leaves `listen()` waiting - for an acknowledgment that is never recorded. + `on_notify_intercept` synchronously in receive order (via + `run_notify_intercept`), handing only unconsumed ones to `on_notify`. `task_status.started()` is called once the dispatcher is ready to accept `send_request`/`notify` calls, so callers can use diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index a62ea5f1d..42798fdc5 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -606,9 +606,8 @@ def _dispatch_notification( `notifications/cancelled` and `notifications/progress` are intercepted here (they correlate against the `_in_flight`/`_pending` tables this layer owns) and still teed to `on_notify` afterwards. The caller's - `on_notify_intercept` then runs, synchronously in receive order, for - correlation state the layer above owns; only notifications it does not - consume are handed to the spawned `on_notify`. + `on_notify_intercept` then runs in receive order; only unconsumed + notifications reach the spawned `on_notify`. """ if msg.method == "notifications/cancelled": rid = cancelled_request_id_from_params(msg.params) diff --git a/src/mcp/shared/subscriptions.py b/src/mcp/shared/subscriptions.py index 8a1fce6ed..ba50917fa 100644 --- a/src/mcp/shared/subscriptions.py +++ b/src/mcp/shared/subscriptions.py @@ -1,14 +1,6 @@ -"""The typed event vocabulary `subscriptions/listen` shares between server and client (2026-07-28, SEP-2575). +"""Typed event vocabulary for `subscriptions/listen` (2026-07-28, SEP-2575), shared by server and client. -A server publishes these events (`mcp.server.subscriptions`); a client -iterating a `Subscription` (`mcp.client.subscriptions`) receives the same -values back. The two conversion helpers map between events and their wire -notifications, one direction per side. - -Every event kind is a level trigger: it says "this changed, refetch if you -care", and carries no payload beyond identity - so two equal pending events -mean exactly what one means, which is what lets both sides bound their -buffers by deduplication. +Every event is a level trigger ("this changed, refetch if you care"), so both sides bound buffers by dedupe. """ from __future__ import annotations @@ -41,10 +33,7 @@ ] SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" -"""The `_meta` key carrying the subscription id on every listen-stream frame. - -The value is the `subscriptions/listen` request's JSON-RPC id, verbatim. -""" +"""The `_meta` key on every listen-stream frame; the value is the `subscriptions/listen` request's JSON-RPC id.""" @dataclass(frozen=True) @@ -92,13 +81,9 @@ def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNot def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None: - """The event a raw listen-stream frame announces (the client's direction). + """The event a raw listen-stream frame announces, or None if it carries none. - Reads the wire dict directly: the client demultiplexes on the dispatcher's - receive path, before the typed notification parse. Returns None for - non-event methods, and for a `resources/updated` frame with no string - `uri` (surface validation rejects those shapes downstream). - """ + Takes the raw wire dict: the client demultiplexes before the typed notification parse.""" if (event := _LIST_CHANGED_EVENTS.get(method)) is not None: return event if method == "notifications/resources/updated": @@ -109,14 +94,9 @@ def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEven def event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: - """Whether `event` is within a stream's honored filter. - - The one admission predicate both sides share: the server delivers only - what it acknowledged, and the client admits only what was acknowledged - - which is what bounds the client's backlog by the filter's width against - any peer, honest or not. `uris` is the honored `resource_subscriptions` - as a set: matching runs on every event, and the filter may name many URIs. - """ + """Whether `event` is within the stream's honored filter (`uris`: the honored resource subscriptions as a set). + + The admission predicate both sides share: server delivery and client intake honor only what was acknowledged.""" if isinstance(event, ToolsListChanged): return honored.tools_list_changed is True if isinstance(event, PromptsListChanged): diff --git a/tests/client/test_session.py b/tests/client/test_session.py index d9333d36c..507c8f69e 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1817,10 +1817,8 @@ async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestPa @pytest.mark.anyio async def test_a_late_ack_for_a_closed_driver_listen_reaches_message_handler(): - """Ack consumption is keyed on the live route registry alone: once a - subscription closes its id is fully released, so a stray ack still - carrying it surfaces through message_handler like any other unowned - frame - nothing consumes it, and nothing remembers the id forever.""" + """Ack consumption is keyed on the live route registry alone: a stray ack for a + closed subscription's id surfaces through message_handler like any other unowned frame.""" seen: list[object] = [] follow_up = anyio.Event() @@ -1858,12 +1856,8 @@ async def handler(msg: object) -> None: @pytest.mark.anyio async def test_a_graceful_result_does_not_outrun_the_events_that_preceded_it(): - """[ack, event, result] written back-to-back: the consumer drains the event - and the wire ack's filter survives, even with the message_handler tee parked - forever - route bookkeeping advances on the dispatcher's receive path in - wire order, not on the spawned tee's schedule (which a slow user handler - stalls arbitrarily). Regression: the result used to settle the route first, - dropping the event and clobbering the ack with a fabricated empty filter.""" + """[ack, event, result] written back-to-back: the event delivers and the wire ack's filter + survives a parked message_handler tee, because routes settle on the dispatcher's receive path in wire order.""" async def parked_handler(message: object) -> None: await anyio.sleep_forever() @@ -1907,9 +1901,7 @@ def _intercept_only_session() -> ClientSession: def test_intercept_settles_only_the_named_listen_route_on_cancelled(): - """SDK-defined demux contract: a server-sent cancel settles exactly the - listen route it names, and is never consumed (the session's v1-parity - swallow of cancelled happens downstream either way).""" + """SDK demux contract: a server-sent cancel settles exactly the listen route it names and is never consumed.""" session = _intercept_only_session() route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] @@ -1920,40 +1912,31 @@ def test_intercept_settles_only_the_named_listen_route_on_cancelled(): def test_intercept_ignores_frames_without_a_route_or_with_broken_meta(): - """SDK-defined demux contract: frames that correlate to no live route - - no open subscriptions, non-mapping `_meta`, an unknown subscription id, or - a malformed event shape - flow through to the normal notification path.""" + """SDK demux contract: frames that correlate to no live route flow through to the normal notification path.""" session = _intercept_only_session() intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] - # Fast path: no open subscriptions, nothing to correlate. assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}) is False route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] route.set_acked(types.SubscriptionFilter(tools_list_changed=True)) - # A params-less frame carries no meta at all. assert intercept("notifications/tools/list_changed", None) is False - # A frame whose `_meta` is not a mapping (constructible on pre-2026 wires) correlates to nothing. + # A non-mapping `_meta` is constructible on pre-2026 wires. assert intercept("notifications/tools/list_changed", {"_meta": "oops"}) is False - # A stamped frame for an id with no route flows through untouched. assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "other"}}) is False - # A resources/updated frame with a non-string uri is not an event (surface validation owns it). + # A non-string uri is not an event; surface validation owns it. meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} assert intercept("notifications/resources/updated", {"uri": 7, **meta}) is False assert route._pending == {} # pyright: ignore[reportPrivateUsage] def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones(): - """SDK-defined demux contract: a well-formed ack for a live route is driver - state (consumed, never surfaced); a malformed one is left to the spawned - path's validation warning; events deliver but still tee.""" + """SDK demux contract: a well-formed ack for a live route is consumed as driver state; malformed acks pass on.""" session = _intercept_only_session() route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} - # A malformed ack is not consumed: the spawned path's validation warning owns it. assert intercept("notifications/subscriptions/acknowledged", {"notifications": ["nope"], **meta}) is False assert route.honored is None - # So is an ack missing the required `notifications` field entirely - it must - # not be read as an (all-refusing) empty filter. + # A missing `notifications` field must not be read as an (all-refusing) empty filter. assert intercept("notifications/subscriptions/acknowledged", dict(meta)) is False assert route.honored is None assert ( @@ -1961,6 +1944,6 @@ def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones(): is True ) assert route.honored == types.SubscriptionFilter(tools_list_changed=True) - # Events are delivered but never consumed - they still tee to message_handler. + # Events deliver but are never consumed - they still tee to message_handler. assert intercept("notifications/tools/list_changed", meta) is False assert list(route._pending) == [ToolsListChanged()] # pyright: ignore[reportPrivateUsage] diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index f923a258d..9579def0f 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -609,10 +609,8 @@ async def aclose(self) -> None: @pytest.mark.anyio async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None: - """A per-request SSE stream that dies having carried no event ids can never - deliver its response; the transport resolves the waiter with CONNECTION_CLOSED - instead of leaving the request pending for the session's lifetime (a listen - stream's consumer would otherwise hang instead of learning it is lost).""" + """A per-request SSE stream that dies having carried no event ids can never deliver its + response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever.""" dying = _DyingSSEStream() def handler(request: httpx.Request) -> httpx.Response: @@ -649,10 +647,7 @@ def _abandoned_request_context( @pytest.mark.anyio async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None: - """The sibling of the non-resumable drop: an id-bearing stream whose - reconnection budget runs out also resolves the waiter - the no-silent-hang - guarantee is unconditional, not conditional on the server never stamping - event ids.""" + """An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED.""" transport = StreamableHTTPTransport("http://test/mcp") send, receive = create_context_streams[SessionMessage | Exception](1) async with httpx.AsyncClient() as http: @@ -671,9 +666,7 @@ async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error @pytest.mark.anyio async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None: - """Teardown race: the session can close the read stream's receive end while - a request's SSE stream is still dying; the resolution write is best-effort - (nobody is waiting) and must not crash the transport task group.""" + """Teardown race: a stream dying after the reader closed resolves best-effort and must not crash.""" transport = StreamableHTTPTransport("http://test/mcp") send, receive = create_context_streams[SessionMessage | Exception](1) receive.close() diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py index 810664fbb..0cc4f133e 100644 --- a/tests/client/test_subscriptions.py +++ b/tests/client/test_subscriptions.py @@ -1,9 +1,6 @@ -"""Behavioral tests for the client-side `subscriptions/listen` driver. +"""Behavioral tests for the client-side `subscriptions/listen` driver (SDK-defined contract). -Everything runs through the public API (`Client.listen`) against in-process -servers, per the repo's mirror rule for `src/mcp/client/subscriptions.py`. -Wire-shape assertions (subscription-id tagging, ack-first ordering) live in -the interaction suite; these tests pin the driver's contract. +Public API only, against in-process servers; wire-shape assertions live in the interaction suite. """ from itertools import count @@ -63,8 +60,7 @@ async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) async def test_listen_surfaces_the_honored_filter_and_subscription_id(): - """Entering waits for the ack: `honored` and `subscription_id` are populated - before the first event is consumed.""" + """Entering waits for the server ack and surfaces the honored filter and subscription id.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus)) as client: with anyio.fail_after(5): @@ -100,8 +96,7 @@ async def test_listen_delivers_all_four_typed_event_kinds(): async def test_unconsumed_duplicate_events_coalesce(): - """Events are level triggers: duplicates pending consumption collapse to one, - so a slow consumer wakes once per distinct fact, not once per publish.""" + """Events are level triggers: duplicates pending consumption collapse to one.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus)) as client: with anyio.fail_after(5): @@ -113,13 +108,11 @@ async def test_unconsumed_duplicate_events_coalesce(): await bus.publish(ResourceUpdated(uri="note://todo")) await anyio.wait_all_tasks_blocked() assert await anext(sub) == ToolsListChanged() - # The duplicates collapsed: the next event is the resource update. assert await anext(sub) == ResourceUpdated(uri="note://todo") async def test_graceful_server_close_ends_the_loop_cleanly(): - """The server's deliberate close (the empty listen result) ends iteration - without an exception - a clean end, not a loss.""" + """The server's deliberate close ends iteration cleanly, after draining prior events.""" bus = InMemorySubscriptionBus() handler = ListenHandler(bus) server = Server("subs", on_subscriptions_listen=handler) @@ -130,13 +123,11 @@ async def test_graceful_server_close_ends_the_loop_cleanly(): await bus.publish(ToolsListChanged()) handler.close() events.extend([event async for event in sub]) - # The event published before the close was still delivered. assert events == [ToolsListChanged()] async def test_abrupt_stream_end_raises_subscription_lost(): - """A stream that dies without the graceful result raises `SubscriptionLost` - from iteration, with the underlying error chained.""" + """A stream dying without the graceful result raises `SubscriptionLost` with the cause chained.""" proceed = anyio.Event() async def dropping_listen( @@ -158,8 +149,7 @@ async def dropping_listen( async def test_listen_on_a_legacy_connection_raises_the_typed_steer(): - """On a 2025 connection `listen` fails fast with the typed error steering to - the legacy verbs, instead of leaking a -32601 from the wire.""" + """On a 2025 connection `listen` fails fast with the typed error steering to the legacy verbs.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus), mode="legacy") as client: with anyio.fail_after(5): @@ -171,8 +161,7 @@ async def test_listen_on_a_legacy_connection_raises_the_typed_steer(): async def test_server_rejection_raises_from_enter_not_from_iteration(): - """A server without the listen handler rejects the request; the error surfaces - immediately from entering the context (raise, don't degrade).""" + """A server without the listen handler fails the open from entering the context.""" server = Server("no-listen") async with Client(server) as client: with anyio.fail_after(5): @@ -182,8 +171,7 @@ async def test_server_rejection_raises_from_enter_not_from_iteration(): async def test_immediate_result_without_ack_opens_already_closed(): - """A server answering with the bare result and no ack yields a subscription - that is already gracefully over: empty honored filter, no events.""" + """A bare result with no ack yields a subscription already gracefully over: no filter, no events.""" async def degenerate_listen( ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams @@ -201,8 +189,7 @@ async def degenerate_listen( async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost(): - """A server tearing the stream down with notifications/cancelled (the - stream-transport spelling) surfaces as a lost subscription.""" + """Server-sent notifications/cancelled for the listen id surfaces as a lost subscription.""" proceed = anyio.Event() async def cancelling_listen( @@ -228,8 +215,7 @@ async def cancelling_listen( async def test_exiting_the_context_frees_the_server_slot(): - """Leaving the block ends the subscription server-side: with a one-slot - handler, a second listen succeeds only because the first was released.""" + """Leaving the block ends the subscription server-side: a one-slot handler admits a second listen.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus, max_subscriptions=1)) as client: with anyio.fail_after(5): @@ -259,14 +245,11 @@ async def test_concurrent_subscriptions_demux_independently(): async def test_change_notifications_still_reach_message_handler(): - """The demux tees: a delivered event's notification still flows to - message_handler (cache eviction and observers keep working); the ack is - driver state and is consumed.""" + """The demux tees: a delivered event's notification still reaches message_handler; the ack never does.""" bus = InMemorySubscriptionBus() seen: list[str] = [] async def on_message(message: object) -> None: - # The ack never reaches the handler - it is driver state, consumed by the demux. assert not isinstance(message, types.SubscriptionsAcknowledgedNotification) if isinstance(message, types.ToolListChangedNotification): # pragma: no branch seen.append("tools-changed") @@ -281,8 +264,7 @@ async def on_message(message: object) -> None: async def test_enter_times_out_when_the_ack_never_arrives(): - """The ack wait rides the session's read timeout, so a wedged server cannot - hang the open forever.""" + """The ack wait rides the session's read timeout, so a wedged server cannot hang the open.""" async def silent_listen( ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams @@ -298,8 +280,7 @@ async def silent_listen( async def test_an_open_stream_outlives_the_session_read_timeout(): - """The listen request itself is exempt from the read timeout: the stream - stays open and delivers long after the per-request deadline passed.""" + """The listen request is exempt from the read timeout: the stream delivers after the deadline.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client: with anyio.fail_after(5): @@ -332,8 +313,7 @@ async def double_acking_listen( async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered(): - """A stamped notification that is not a change event (a log line on the - stream) never surfaces as an event; it flows to message_handler as usual.""" + """A stamped non-event notification never surfaces as an event; it flows to message_handler.""" proceed = anyio.Event() async def logging_listen( @@ -368,8 +348,7 @@ async def on_message(message: object) -> None: async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost(): - """Closing the client while a watcher task is parked on the stream must not - strand it: teardown settles every open route as lost.""" + """Session teardown settles every open route as lost, unblocking parked consumers.""" bus = InMemorySubscriptionBus() outcome: list[str] = [] entered = anyio.Event() @@ -386,14 +365,11 @@ async def consume(client: Client) -> None: async with Client(_bus_server(bus)) as client: # pragma: no branch tg.start_soon(consume, client) await entered.wait() - # The client exited above while the watcher was still parked on the - # stream; teardown settles the route, unblocking it with a lost end. assert outcome == ["lost"] async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter(): - """A stream torn down before it was ever acknowledged is a failed open: - enter raises instead of yielding a handle with a fabricated empty filter.""" + """A stream torn down before it was ever acknowledged is a failed open: enter raises.""" async def cancel_first_listen( ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams @@ -414,8 +390,7 @@ async def cancel_first_listen( async def test_listen_on_an_exited_session_raises_and_leaks_no_route(): - """Opening against a session whose context already exited fails loudly, and - the demux registration does not outlive the failed open.""" + """Opening on an exited session fails loudly and leaves no demux registration behind.""" bus = InMemorySubscriptionBus() client = Client(_bus_server(bus)) async with client: @@ -442,8 +417,7 @@ async def test_listen_on_a_never_entered_session_raises_runtime_error(): async def test_a_retained_handle_after_exit_does_not_serve_stale_events(): - """Leaving the block abandons the backlog: a stashed handle must not replay - buffered events as if they were live.""" + """Leaving the block abandons the backlog: a stashed handle must not replay buffered events.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus)) as client: with anyio.fail_after(5): @@ -455,8 +429,7 @@ async def test_a_retained_handle_after_exit_does_not_serve_stale_events(): async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler(): - """Acks for ids the driver never minted flow to message_handler - the raw - escape-hatch listen (send_request directly) observes its ack there.""" + """Acks for ids the driver never minted flow to message_handler (the raw-listen escape hatch).""" proceed = anyio.Event() async def stray_acking_listen( @@ -492,8 +465,7 @@ async def on_message(message: object) -> None: async def test_a_bare_string_for_resource_subscriptions_is_rejected(): - """`resource_subscriptions="uri"` would explode into per-character URIs; the - classic footgun is rejected before anything touches the wire.""" + """A bare string would explode into per-character URIs; it is rejected before touching the wire.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus)) as client: with pytest.raises(TypeError, match="sequence of URIs"): @@ -501,16 +473,13 @@ async def test_a_bare_string_for_resource_subscriptions_is_rejected(): def test_the_route_admits_only_honored_events_and_only_while_live(): - """Admission control at the route: nothing before the ack; after it, kind - events match the honored flags exactly, while a ResourceUpdated is admitted - whenever URI subscriptions were honored at all (the spec lets the stamped - URI be a sub-resource of a subscribed one); nothing once the stream ended.""" + """Route admission: nothing before the ack, only honored events while live, nothing after the end.""" route = ListenRoute() - route.deliver(ToolsListChanged()) # before the ack nothing is admissible + route.deliver(ToolsListChanged()) assert route._pending == {} # pyright: ignore[reportPrivateUsage] route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"])) route.deliver(PromptsListChanged()) # kind not honored - route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: admitted + route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: spec says admit route.deliver(ResourceUpdated(uri="note://todo")) route.deliver(ToolsListChanged()) route.deliver(ToolsListChanged()) # duplicate pending consumption collapses @@ -525,10 +494,8 @@ def test_the_route_admits_only_honored_events_and_only_while_live(): def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory(): - """`_MAX_PENDING_EVENTS` backstops the one backlog admission cannot bound: - URI admission is deliberately loose (sub-resources), so a flooding peer - settles the route lost - re-listen and refetch - instead of growing the - pending map without bound.""" + """A peer flooding distinct URIs trips the `_MAX_PENDING_EVENTS` backstop: the route + settles lost instead of growing client memory without bound.""" route = ListenRoute() route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"])) for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage] @@ -538,14 +505,13 @@ def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory( assert route.end == "lost" assert route.error is not None assert "backlog" in route.error.error.message - # The overflowing event was not queued; the drained backlog stays at the cap. + # The overflowing event was not queued. assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage] async def test_a_cancelled_on_event_barrier_does_not_lose_the_event(): - """The event stays pending while the barrier runs: a consumer-side timeout - cancelling `anext` mid-barrier leaves the level trigger queued, and the - next `anext` re-runs the (idempotent) barrier and returns it.""" + """Cancelling `anext` mid-barrier leaves the event queued; the next `anext` re-runs the + idempotent barrier and returns it.""" bus = InMemorySubscriptionBus() entered = anyio.Event() release = anyio.Event() @@ -576,8 +542,7 @@ async def first_attempt() -> None: async def test_events_outside_the_honored_filter_are_never_delivered(): - """A server that violates its acknowledged filter cannot reach the consumer - (or grow the backlog): the route admits only honored events.""" + """A server violating its acknowledged filter cannot reach the consumer or grow the backlog.""" proceed = anyio.Event() async def overreaching_listen( @@ -608,9 +573,7 @@ async def overreaching_listen( async def test_the_on_event_barrier_completes_before_each_event_is_returned(): - """`on_event` is awaited between the route handing over an event and the - iterator returning it - the seam consumers use for must-happen-before work - (the Client wires cache eviction here).""" + """`on_event` is awaited before the iterator returns each event (the Client wires cache eviction here).""" bus = InMemorySubscriptionBus() order: list[str] = [] @@ -627,8 +590,7 @@ async def barrier(event: ServerEvent) -> None: async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists(): - """`Client.listen` wires its response-cache evictor as the event barrier; - with caching disabled there is no barrier to pay for.""" + """`Client.listen` wires the response-cache evictor as the barrier only when a cache exists.""" bus = InMemorySubscriptionBus() async with Client(_bus_server(bus)) as cached_client: with anyio.fail_after(5): @@ -643,9 +605,8 @@ async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_ async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The barrier evicts through the same notification mapping as the - message_handler wrapper, and a raising store costs a log line, not the - event delivery.""" + """The barrier evicts through the same notification mapping as the message_handler wrapper; + a raising store costs a log line, not the delivery.""" client = Client(_bus_server(InMemorySubscriptionBus())) cache = client._response_cache # pyright: ignore[reportPrivateUsage] assert cache is not None @@ -670,9 +631,8 @@ async def broken(notification: types.ServerNotification) -> None: async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A raw caller occupying the driver's next minted id fails that ONE listen - with a typed error from enter; the session survives and the next listen - (a fresh id) opens normally.""" + """A raw caller occupying the driver's next minted id fails that one listen from enter; + the session survives and the next listen opens normally.""" monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000)) bus = InMemorySubscriptionBus() async with Client(_bus_server(bus)) as client: @@ -699,10 +659,8 @@ async def raw_listen() -> None: with pytest.raises(MCPError) as exc_info: await client.listen(tools_list_changed=True).__aenter__() assert "already in flight" in exc_info.value.error.message - # The failed open released the colliding id's demux registration: - # only the raw caller may see frames stamped with it. + # The failed open released the colliding id's demux registration. assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage] raw_scope.cancel() - # The session is intact: the next listen mints a fresh id and opens. async with client.listen(tools_list_changed=True) as sub: # pragma: no branch assert sub.subscription_id == "listen-7001" diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index 99001c3d8..f58380148 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -139,8 +139,7 @@ async def listen() -> None: async def test_client_listen_delivers_one_typed_event_then_closes() -> None: - """tutorial003: `Client.listen` yields a typed event for the subscribed URI and - leaving the block closes the stream (two clients, one server instance).""" + """tutorial003: `Client.listen` yields typed events for the subscribed URI; leaving the block closes the stream.""" results: list[str] = [] async def watch() -> None: @@ -149,8 +148,7 @@ async def watch() -> None: with anyio.fail_after(10): async with anyio.create_task_group() as tg: tg.start_soon(watch) - # The watcher parks on its stream (the ack round trip is complete) - # before the edit is published. + # Let the watcher park on its stream (ack complete) before the edit is published. await anyio.wait_all_tasks_blocked() async with Client(tutorial001.mcp) as editor: # pragma: no branch await editor.call_tool("edit_note", {"name": "todo", "text": "water plants"}) diff --git a/tests/interaction/lowlevel/test_subscriptions.py b/tests/interaction/lowlevel/test_subscriptions.py index ce47f4c47..88b2ab465 100644 --- a/tests/interaction/lowlevel/test_subscriptions.py +++ b/tests/interaction/lowlevel/test_subscriptions.py @@ -18,8 +18,7 @@ @requirement("subscriptions:listen:client:graceful-close") async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None: - """`ListenHandler.close()` sends the stamped result as the final frame; the - client loop drains what was published first, then ends without an exception.""" + """`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly.""" bus = InMemorySubscriptionBus() handler = ListenHandler(bus) server = Server("subs", on_subscriptions_listen=handler) @@ -35,8 +34,7 @@ async def test_a_graceful_server_close_ends_iteration_after_buffered_events(conn @requirement("subscriptions:listen:client:lost") async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None: - """A server erroring the listen request after the ack (an abrupt end, not the - graceful close) surfaces as SubscriptionLost at the iteration site.""" + """Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration.""" proceed = anyio.Event() async def dropping_listen( diff --git a/tests/interaction/mcpserver/test_subscriptions.py b/tests/interaction/mcpserver/test_subscriptions.py index bf02ec4ca..047b049d9 100644 --- a/tests/interaction/mcpserver/test_subscriptions.py +++ b/tests/interaction/mcpserver/test_subscriptions.py @@ -12,7 +12,6 @@ def _notebook() -> MCPServer: - """A server whose tools publish both change kinds through their Context.""" mcp = MCPServer("notebook") @mcp.tool() @@ -31,9 +30,8 @@ async def edit_note(name: str, ctx: Context) -> str: @requirement("subscriptions:listen:client:honored-surfacing") @requirement("subscriptions:listen:client:iteration") async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None: - """Entering waits for the acknowledgment (the honored filter is on the handle - before any event), and iteration yields the typed events the server's - notify calls produce - only the kinds this stream opted in to.""" + """Entering waits for the ack (honored is set before any event); iteration yields + only the typed event kinds this stream opted in to.""" mcp = _notebook() async with connect(mcp) as client: with anyio.fail_after(10): @@ -53,8 +51,7 @@ async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connec @requirement("subscriptions:listen:client:era-guard") async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None: - """On 2025-era connections the guard fires before anything touches the wire, - steering to the legacy verbs.""" + """On 2025-era connections the guard fires before anything touches the wire, steering to the legacy verbs.""" mcp = _notebook() async with connect(mcp) as client: with anyio.fail_after(10): diff --git a/tests/shared/test_dispatcher.py b/tests/shared/test_dispatcher.py index c57b4f92d..c6ebb401f 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -514,8 +514,7 @@ async def first() -> None: @pytest.mark.anyio async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory): - """The intercept observes each inbound notification before `on_notify` is - scheduled; a frame it consumes never reaches `on_notify`, the rest do.""" + """The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do.""" intercepted: list[str] = [] def intercept(method: str, params: Mapping[str, Any] | None) -> bool: @@ -533,9 +532,7 @@ def intercept(method: str, params: Mapping[str, Any] | None) -> bool: @pytest.mark.anyio async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory): - """Notifications written before a response are intercepted before that - response resolves: correlation state fed through the intercept is ordered - against `send_raw_request`'s return, whatever the spawned handlers do.""" + """Notifications written before a response are intercepted before it resolves, whatever spawned handlers do.""" seen: list[str] = [] def intercept(method: str, params: Mapping[str, Any] | None) -> bool: @@ -559,8 +556,7 @@ async def notify_then_answer( @pytest.mark.anyio async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory): - """An intercept exception costs only that interception: the notification - still reaches `on_notify` and the receive loop survives.""" + """An intercept exception costs only that interception: the frame still reaches `on_notify`, the loop survives.""" def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool: raise RuntimeError("intercept exploded") From 6573774550b551ec835015c631159797f6328567 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:35:03 +0000 Subject: [PATCH 6/8] Point moved docs pages at the client driver section --- docs/client/index.md | 2 +- docs/migration.md | 2 +- docs/whats-new.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/client/index.md b/docs/client/index.md index 01287da05..be8a73c2a 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read. `read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). -A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**. +A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and **[Subscriptions](../handlers/subscriptions.md)** tells both sides of the story - the client end is [`client.listen(...)`](../handlers/subscriptions.md#the-client-side). ## Prompts diff --git a/docs/migration.md b/docs/migration.md index b0682a573..923fb5861 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1551,7 +1551,7 @@ async with client.listen(resource_subscriptions=["note://todo"]) as sub: ... ``` -See the [Subscriptions](advanced/subscriptions.md) page for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). +See the [Subscriptions](handlers/subscriptions.md) page for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). ### Roots, Sampling, and Logging methods deprecated (SEP-2577) diff --git a/docs/whats-new.md b/docs/whats-new.md index d197833db..c19b11f9f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -190,9 +190,9 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and ### Change notifications become one stream -At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. Two honest caveats as of `2.0.0b1`: the Python `Client` cannot open the listen stream yet (the driver ships in a later pre-release), and over stdio the server does not serve it. The net for a Python *client* on that release is that nothing delivers change notifications on a 2026-07-28 connection; a host that relies on `resources/updated` should connect with `mode="legacy"` until the driver lands. +At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet. -**[Subscriptions](handlers/subscriptions.md)** on the server, and **[Deploy & scale](run/deploy.md)** for the bus. +**[Subscriptions](handlers/subscriptions.md)** covers both sides, and **[Deploy & scale](run/deploy.md)** the bus. ### The rest, quickly From d9716804965b7b56157a94c7887ec8713450664d Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:43:54 +0000 Subject: [PATCH 7/8] Move the docs watch-loop example into docs_src and prove it --- docs/handlers/subscriptions.md | 16 +--- docs_src/subscriptions/tutorial004.py | 20 +++++ tests/docs_src/test_subscriptions.py | 117 +++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 docs_src/subscriptions/tutorial004.py diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 075a4317c..30094d542 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -98,20 +98,8 @@ Consuming a subscription is one context manager: * Leaving the block ends the subscription, with the transport's own spelling: over streamable HTTP the request's response stream is closed (that is the 2026 cancellation signal), on stream transports `notifications/cancelled` is sent. * The stream's two endings are control flow. The server closing gracefully simply ends the `async for`; an abrupt drop raises `SubscriptionLost`. The distinction is diagnostic — a clean end versus a connection worth suspecting — not a difference in what to do next: either way the stream is gone, nothing is replayed, and a watcher that still cares re-listens and refetches. Servers close streams gracefully for their own reasons — shutdown, or shedding a subscriber whose backlog grew past bounds, as this SDK's `ListenHandler` does — so a graceful close is not a signal to stop watching: -```python -async def watch(client: Client, uri: str) -> None: - while True: - try: - async with client.listen(resource_subscriptions=[uri]) as sub: - await client.read_resource(uri) # refetch: no replay across streams - async for _event in sub: - await client.read_resource(uri) - except SubscriptionLost: - pass - # Graceful close or abrupt drop, the stream is gone either way. Back - # off before re-listening - a graceful close may be the server - # shedding load, and reconnecting instantly recreates the pressure. - await anyio.sleep(1) +```python title="watch.py" hl_lines="15 16" +--8<-- "docs_src/subscriptions/tutorial004.py" ``` * Checking the acknowledgment (the spec's client SHOULD) is reading `sub.honored` — the kinds this stream will actually receive. A server may narrow the filter it agrees to honor (a multi-tenant server declining a URI, say), and `sub.honored` is that delivery contract — it says nothing about what exists in the catalog. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id. diff --git a/docs_src/subscriptions/tutorial004.py b/docs_src/subscriptions/tutorial004.py new file mode 100644 index 000000000..9fefce49f --- /dev/null +++ b/docs_src/subscriptions/tutorial004.py @@ -0,0 +1,20 @@ +import anyio + +from mcp import Client +from mcp.client.subscriptions import SubscriptionLost + + +async def watch(client: Client, uri: str) -> None: + """Keep one resource fresh for as long as the client lives.""" + while True: + try: + async with client.listen(resource_subscriptions=[uri]) as sub: + await client.read_resource(uri) # refetch: no replay across streams + async for _event in sub: + await client.read_resource(uri) + except SubscriptionLost: + pass + # Graceful close or abrupt drop, the stream is gone either way. Back + # off before re-listening - a graceful close may be the server + # shedding load, and reconnecting instantly recreates the pressure. + await anyio.sleep(1) diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index f58380148..79760388d 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -5,10 +5,19 @@ import anyio import mcp_types as types import pytest +from trio.testing import MockClock -from docs_src.subscriptions import tutorial001, tutorial002, tutorial003 +from docs_src.subscriptions import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client -from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + InMemorySubscriptionBus, + ListenHandler, + ResourceUpdated, + ToolsListChanged, +) # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -153,3 +162,107 @@ async def watch() -> None: async with Client(tutorial001.mcp) as editor: # pragma: no branch await editor.call_tool("edit_note", {"name": "todo", "text": "water plants"}) assert results == ["changed: note://todo"] + + +class _Reads: + """Counts server-side resource reads and lets tests await a count.""" + + def __init__(self) -> None: + self.count = 0 + self._bump = anyio.Event() + + def hit(self) -> None: + self.count += 1 + self._bump.set() + self._bump = anyio.Event() + + async def wait_for(self, count: int) -> None: + with anyio.fail_after(5): + while self.count < count: + await self._bump.wait() + + +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_watcher_re_listens_after_both_endings() -> None: + """tutorial004: watch() refetches on entry and per event, and re-listens after + a graceful server close and after `SubscriptionLost`. + + Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.""" + DROP_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"subscription_id": {"type": "string"}}, + "required": ["subscription_id"], + } + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + reads = _Reads() + stream = _Stream() + + async def read_resource( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + reads.hit() + return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text="fresh")]) + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="drop", description="End a subscription abruptly.", input_schema=DROP_SCHEMA)] + ) + + async def drop_stream(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + # The abrupt ending: the server cancels the named subscription without a + # graceful close. Sent request-scoped: the 2026 wire has no standalone stream. + subscription_id = (params.arguments or {})["subscription_id"] + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=subscription_id)), + related_request_id=ctx.request_id, + ) + return types.CallToolResult(content=[]) + + server = Server( + "watched", + on_read_resource=read_resource, + on_list_tools=list_tools, + on_call_tool=drop_stream, + on_subscriptions_listen=handler, + ) + + def teed_subscription_id(index: int) -> Any: + updated = stream.received[index] + assert isinstance(updated, types.ResourceUpdatedNotification) + assert updated.params.meta is not None + return updated.params.meta[SUBSCRIPTION_ID_META_KEY] + + async with Client(server, mode="2026-07-28", message_handler=stream.handler) as client: + async with anyio.create_task_group() as tg: + tg.start_soon(tutorial004.watch, client, "note://todo") + + # Stream 1: the entry refetch proves the ack arrived; an event drives one more refetch. + await reads.wait_for(1) + await bus.publish(ResourceUpdated(uri="note://todo")) + await reads.wait_for(2) + await stream.wait_for(1) + + # Graceful close: the watcher backs off, re-listens, and refetches. + handler.close() + await reads.wait_for(3) + await bus.publish(ResourceUpdated(uri="note://todo")) + await reads.wait_for(4) + await stream.wait_for(2) + second_id = teed_subscription_id(1) + assert second_id != teed_subscription_id(0) + + # Abrupt ending: the watcher swallows SubscriptionLost and re-listens again. + await client.call_tool("drop", {"subscription_id": second_id}) + await reads.wait_for(5) + await bus.publish(ResourceUpdated(uri="note://todo")) + await reads.wait_for(6) + await stream.wait_for(3) + assert teed_subscription_id(2) != second_id + + tg.cancel_scope.cancel() From d7c16b1942e21d95cb2fb7c0a01f7627c41c96df Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:50:43 +0000 Subject: [PATCH 8/8] Deep-link the migration guide and spell out the watcher test choreography --- docs/migration.md | 2 +- tests/docs_src/test_subscriptions.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 923fb5861..cf64aaf97 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1551,7 +1551,7 @@ async with client.listen(resource_subscriptions=["note://todo"]) as sub: ... ``` -See the [Subscriptions](handlers/subscriptions.md) page for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). +See the [Subscriptions](handlers/subscriptions.md#the-client-side) page for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). ### Roots, Sampling, and Logging methods deprecated (SEP-2577) diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index 79760388d..b74618dcf 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -190,7 +190,14 @@ async def test_watcher_re_listens_after_both_endings() -> None: """tutorial004: watch() refetches on entry and per event, and re-listens after a graceful server close and after `SubscriptionLost`. - Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.""" + Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time. + + Steps: + 1. Stream 1: the entry refetch proves the ack arrived; a publish drives an event refetch. + 2. handler.close() ends stream 1 gracefully; the watcher backs off, re-listens (stream 2, + a new subscription id), and refetches. + 3. The drop tool cancels stream 2 abruptly; the watcher swallows SubscriptionLost, + re-listens (stream 3), and refetches on the next publish.""" DROP_SCHEMA: dict[str, Any] = { "type": "object", "properties": {"subscription_id": {"type": "string"}},