Add the client-side subscriptions/listen driver (#3047)
This commit is contained in:
parent
6d2e908f2b
commit
9bdc03d54e
10 changed files with 2587 additions and 295 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
282
src/mcp/client/subscriptions.py
Normal file
282
src/mcp/client/subscriptions.py
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
106
src/mcp/shared/subscriptions.py
Normal file
106
src/mcp/shared/subscriptions.py
Normal 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
|
||||
Loading…
Reference in a new issue