Add the client-side subscriptions/listen driver (#3047)

This commit is contained in:
Max 2026-07-07 14:26:09 +01:00 • committed by GitHub
commit 9bdc03d54e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2587 additions and 295 deletions

View file

@ -6,7 +6,7 @@ import hashlib
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 @@ from mcp.client.session import (
SamplingFnT,
)
from mcp.client.streamable_http import streamable_http_client
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
from mcp.server.runner import modern_on_request
@ -67,6 +69,7 @@ from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
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__)
@ -666,13 +669,68 @@ class Client:
# Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST).
return await self._drive_input_required(first, retry)
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)
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).
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 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.
MCPError: The server rejected the request or the connection failed first.
SubscriptionLost: The stream ended before it was acknowledged.
TimeoutError: The 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,
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.
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
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,
)
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""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,

View file

@ -16,6 +16,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre
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 typing_extensions import Self, TypeVar, deprecated
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,
@ -48,9 +50,10 @@ from mcp.shared.inbound import (
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_wire
from mcp.shared.transport_context import TransportContext
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
@ -360,6 +363,8 @@ class ClientSession:
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; 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:
raise ValueError("pass read_stream/write_stream or dispatcher, not both")
@ -388,7 +393,9 @@ class ClientSession:
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)
@ -422,6 +429,7 @@ class ClientSession:
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 +867,23 @@ class ClientSession:
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 +1241,62 @@ class ClientSession:
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
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:
"""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 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 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:
return False
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:
# 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:
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
) -> None:
@ -1259,7 +1331,7 @@ class ClientSession:
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.
# Never surfaced (v1 parity): the dispatcher already applied it; listen cancels settled by the intercept.
return
try:
if isinstance(notification, types.LoggingMessageNotification):

View file

@ -13,6 +13,7 @@ import httpx
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,
@ -327,6 +328,15 @@ class StreamableHTTPTransport:
) as response:
if response.status_code == 202:
logger.debug("Received 202 Accepted")
if isinstance(message, JSONRPCRequest):
# A request's response arrives on this POST's body; 202 says
# none will follow. Resolve rather than park the caller forever.
await self._resolve_abandoned_request(
ctx.read_stream_writer,
message.id,
"server answered a request with 202 Accepted",
code=INVALID_REQUEST,
)
return
if response.status_code >= 400:
@ -438,9 +448,29 @@ class StreamableHTTPTransport:
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: 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"
)
async def _resolve_abandoned_request(
self, read_stream_writer: StreamWriter, request_id: RequestId, message: str, *, code: int = CONNECTION_CLOSED
) -> None:
"""Resolve a request whose response can never arrive with a synthesized error.
Best-effort: a closed read stream means the session is tearing down.
"""
error_data = ErrorData(code=code, 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,
@ -450,9 +480,17 @@ class StreamableHTTPTransport:
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 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:
# 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, original_request_id, "SSE stream ended and reconnection attempts were exhausted"
)
return
# Always wait - use server value or default
@ -462,11 +500,6 @@ class StreamableHTTPTransport:
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()
@ -564,6 +597,12 @@ class StreamableHTTPTransport:
scope=anyio.CancelScope(),
modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS,
)
superseded = self._in_flight_posts.get(message.id)
if superseded is not None:
# A reused id means the waiter belongs to this attempt now:
# sever the old POST so its zombie stream cannot answer,
# fail, or resolve the successor's request.
superseded.scope.cancel()
self._in_flight_posts[message.id] = post
tg.start_soon(self._run_request_post, handle_request_async, post, message.id)
else:

View file

@ -0,0 +1,282 @@
"""Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575).
`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
from collections.abc import AsyncIterator, Awaitable, Callable, 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,
event_matches,
)
if TYPE_CHECKING:
from mcp.client.session import ClientSession
__all__ = [
"ListenNotSupportedError",
"OnEvent",
"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."""
_MAX_PENDING_EVENTS = 1024
"""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."""
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 stream ended without the server's graceful close; re-listen and refetch."""
class ListenRoute:
"""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
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()
def set_acked(self, honored: types.SubscriptionFilter) -> None:
"""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 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
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 and wakes both waiters."""
if self.end is None:
self.end = end
self.error = error
self.acked.set()
self._wake.set()
async def next_event(self) -> ServerEvent | _SubscriptionEnd:
"""Peek the next pending event, or the stream's end once the backlog drains.
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 so a deliver landing after the checks cannot be missed.
wake = self._wake
if self.end == "local":
return self.end
if self._pending:
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."""
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.
Produced by `listen()` / `Client.listen()`, not constructed directly.
"""
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, stamped into every frame's `_meta`."""
self.honored = honored
"""The subset of the requested filter the server agreed to deliver."""
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
raise StopAsyncIteration
if self._on_event is not None:
# 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
@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] = (),
on_event: OnEvent | None = None,
) -> AsyncIterator[Subscription]:
"""Open one `subscriptions/listen` stream on `session` (2026-07-28 only).
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: 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)
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:
# Deliberately no result timeout: the response arrives when the stream ends.
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 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
# 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 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:
# 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")
yield Subscription(route, request_id, route.honored, on_event)
finally:
route.settle("local")
driver_scope.cancel()
session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage]

View file

@ -16,14 +16,14 @@ from mcp.server.elicitation import (
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

View file

@ -13,6 +13,8 @@ the stream. This module provides the two pieces a server needs:
`MCPServer` registers one automatically; lowlevel `Server` users pass an
instance as `on_subscriptions_listen=`.
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
`_meta["io.modelcontextprotocol/subscriptionId"]`, and never delivers an
@ -24,7 +26,6 @@ from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Protocol
import anyio
@ -33,57 +34,40 @@ import anyio.streams.memory
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_matches,
event_to_notification,
)
__all__ = [
"SUBSCRIPTION_ID_META_KEY",
"InMemorySubscriptionBus",
"ListenHandler",
"PromptsListChanged",
"ResourceUpdated",
"ResourcesListChanged",
"ServerEvent",
"SubscriptionBus",
"ToolsListChanged",
]
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
ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated
"""An event a server publishes for delivery to listen subscribers."""
class SubscriptionBus(Protocol):
"""Fan-out seam between event publishers and open listen streams.
@ -170,32 +154,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
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.
@ -244,7 +202,7 @@ class ListenHandler:
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:
@ -273,7 +231,7 @@ class ListenHandler:
)
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)

View file

@ -28,7 +28,15 @@ from mcp_types import CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, REQUEST
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 @@ class DirectDispatcher:
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 @@ class DirectDispatcher:
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 @@ class DirectDispatcher:
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 @@ class DirectDispatcher:
# 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)

View file

@ -16,6 +16,7 @@ See `JSONRPCDispatcher` for the production implementation and
embedding a server in-process.
"""
import logging
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
@ -26,20 +27,32 @@ from mcp_types import RequestId
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)
def as_request_id(value: object) -> RequestId | None:
"""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
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).
@ -211,6 +224,25 @@ OnRequest = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any]
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`.
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 `intercept`, containing a raise to that one notification (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.
@ -225,6 +257,7 @@ class Dispatcher(Outbound, Protocol[TransportT_co]):
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
@ -232,7 +265,9 @@ class Dispatcher(Outbound, Protocol[TransportT_co]):
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` 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

View file

@ -44,9 +44,12 @@ from mcp.shared.dispatcher import (
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 (
@ -107,12 +110,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)
@ -297,6 +296,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
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
@ -466,6 +466,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
@ -474,6 +475,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
`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:
@ -603,7 +605,9 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
`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 in receive order; only unconsumed
notifications reach the spawned `on_notify`.
"""
if msg.method == "notifications/cancelled":
rid = cancelled_request_id_from_params(msg.params)
@ -630,6 +634,8 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
)
case _:
pass
if run_notify_intercept(self._on_notify_intercept, msg.method, msg.params):
return
try:
transport_ctx = self._transport_builder(metadata)
except Exception:

View file

@ -0,0 +1,106 @@
"""Typed event vocabulary for `subscriptions/listen` (2026-07-28, SEP-2575), shared by server and client.
Every event is a level trigger ("this changed, refetch if you care"), so both sides bound buffers by dedupe.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from mcp_types import (
NotificationParams,
PromptListChangedNotification,
ResourceListChangedNotification,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
ServerNotification,
SubscriptionFilter,
ToolListChangedNotification,
)
__all__ = [
"SUBSCRIPTION_ID_META_KEY",
"PromptsListChanged",
"ResourceUpdated",
"ResourcesListChanged",
"ServerEvent",
"ToolsListChanged",
"event_from_wire",
"event_matches",
"event_to_notification",
]
SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId"
"""The `_meta` key on every listen-stream frame; the value is the `subscriptions/listen` request's JSON-RPC id."""
@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))
_LIST_CHANGED_EVENTS: dict[str, ServerEvent] = {
"notifications/tools/list_changed": ToolsListChanged(),
"notifications/prompts/list_changed": PromptsListChanged(),
"notifications/resources/list_changed": ResourcesListChanged(),
}
def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None:
"""The event a raw listen-stream frame announces, or None if it carries none.
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":
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 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):
return honored.prompts_list_changed is True
if isinstance(event, ResourcesListChanged):
return honored.resources_list_changed is True
return event.uri in uris