Serve subscriptions/listen with a pluggable event bus (SEP-2575) (#3035)

This commit is contained in:
Max 2026-06-30 23:01:04 +01:00 • committed by GitHub
commit ca10dade2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1722 additions and 66 deletions

View file

@ -383,6 +383,13 @@ async def handle_modern_request(
await _write(rej, scope, receive, send)
return
if req.method == "subscriptions/listen" and not has_sse:
# A listen response IS a notification stream, never JSON (the
# json_response carve-out below), so this one method requires the
# SSE accept even in JSON-response mode; SSE mode gated it above.
await Response(status_code=406)(scope, receive, send)
return
duplicated = find_duplicated_routing_header(request.headers.items())
if duplicated is not None:
# The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping.
@ -412,7 +419,10 @@ async def handle_modern_request(
progress_token=progress_token_from_params(req.params),
)
if json_response:
if json_response and req.method != "subscriptions/listen":
# A listen response IS a notification stream, so it always takes the
# SSE path below regardless of the JSON-response preference (the
# TypeScript and Go SDKs route it the same way).
msg = await _to_jsonrpc_response(
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
)

View file

@ -561,12 +561,22 @@ class Server(Generic[LifespanResultT]):
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
extensions: dict[str, dict[str, Any]] | None = None,
*,
protocol_version: str | None = None,
) -> types.ServerCapabilities:
"""Convert existing handlers to a ServerCapabilities object.
`extensions` is the SEP-2133 extension map (identifier -> settings)
advertised under `ServerCapabilities.extensions`; it defaults to
`self.extensions`.
`protocol_version` makes the subscription-delivered bits era-honest:
at 2026-07-28+ versions, change notifications are delivered only on
`subscriptions/listen` streams, so the `listChanged` flags and
`resources.subscribe` derive from whether that method is served -
`notification_options` and the legacy `resources/subscribe` handler
(which the modern wire cannot dispatch) are ignored. When omitted, the
handshake-era derivation applies unchanged.
"""
notification_options = notification_options or NotificationOptions()
prompts_capability = None
@ -575,20 +585,29 @@ class Server(Generic[LifespanResultT]):
logging_capability = None
completions_capability = None
if protocol_version in MODERN_PROTOCOL_VERSIONS:
listen_served = "subscriptions/listen" in self._request_handlers
prompts_changed = tools_changed = resources_changed = subscribe = listen_served
else:
prompts_changed = notification_options.prompts_changed
tools_changed = notification_options.tools_changed
resources_changed = notification_options.resources_changed
subscribe = "resources/subscribe" in self._request_handlers
# Set prompt capabilities if handler exists
if "prompts/list" in self._request_handlers:
prompts_capability = types.PromptsCapability(list_changed=notification_options.prompts_changed)
prompts_capability = types.PromptsCapability(list_changed=prompts_changed)
# Set resource capabilities if handler exists
if "resources/list" in self._request_handlers:
resources_capability = types.ResourcesCapability(
subscribe="resources/subscribe" in self._request_handlers,
list_changed=notification_options.resources_changed,
subscribe=subscribe,
list_changed=resources_changed,
)
# Set tool capabilities if handler exists
if "tools/list" in self._request_handlers:
tools_capability = types.ToolsCapability(list_changed=notification_options.tools_changed)
tools_capability = types.ToolsCapability(list_changed=tools_changed)
# Set logging capabilities if handler exists
if "logging/setLevel" in self._request_handlers:
@ -638,7 +657,7 @@ class Server(Generic[LifespanResultT]):
"""
return types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=self.get_capabilities(),
capabilities=self.get_capabilities(protocol_version=ctx.protocol_version),
server_info=self.server_info,
instructions=self.instructions,
)

View file

@ -16,6 +16,13 @@ from mcp.server.elicitation import (
elicit_with_validation,
)
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.subscriptions import (
PromptsListChanged,
ResourcesListChanged,
ResourceUpdated,
SubscriptionBus,
ToolsListChanged,
)
from mcp.shared.exceptions import MCPDeprecationWarning
if TYPE_CHECKING:
@ -59,6 +66,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
_mcp_server: MCPServer | None
_input_params: InputResponseRequestParams | None
_subscriptions: SubscriptionBus | None
# TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely.
def __init__(
@ -67,6 +75,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None,
mcp_server: MCPServer | None = None,
input_params: InputResponseRequestParams | None = None,
subscriptions: SubscriptionBus | None = None,
# TODO(Marcelo): We should drop this kwargs parameter.
**kwargs: Any,
):
@ -74,13 +83,14 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
self._request_context = request_context
self._mcp_server = mcp_server
self._input_params = input_params
self._subscriptions = subscriptions
@property
def mcp_server(self) -> MCPServer:
"""Access to the MCPServer instance."""
if self._mcp_server is None: # pragma: no cover
if self._mcp_server is None:
raise ValueError("Context is not available outside of a request")
return self._mcp_server # pragma: no cover
return self._mcp_server
@property
def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
@ -97,7 +107,9 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
request's own target — their keys are ones that handler minted — so a nested
invocation always starts on round one.
"""
return Context(request_context=self._request_context, mcp_server=self._mcp_server)
return Context(
request_context=self._request_context, mcp_server=self._mcp_server, subscriptions=self._subscriptions
)
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for the current operation.
@ -109,6 +121,34 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
"""
await self.request_context.session.report_progress(progress, total, message)
@property
def _bus(self) -> SubscriptionBus:
if self._subscriptions is None:
raise ValueError("Context is not available outside of a request")
return self._subscriptions
async def notify_tools_changed(self) -> None:
"""Publish a tools list-changed event to `subscriptions/listen` subscribers."""
await self._bus.publish(ToolsListChanged())
async def notify_prompts_changed(self) -> None:
"""Publish a prompts list-changed event to `subscriptions/listen` subscribers."""
await self._bus.publish(PromptsListChanged())
async def notify_resources_changed(self) -> None:
"""Publish a resources list-changed event to `subscriptions/listen` subscribers."""
await self._bus.publish(ResourcesListChanged())
async def notify_resource_updated(self, uri: str | AnyUrl) -> None:
"""Publish a resource-updated event for `uri` to `subscriptions/listen` subscribers.
The URI is matched as an exact string against each stream's filter.
Reaches `subscriptions/listen` streams only; clients on earlier
protocol versions that used `resources/subscribe` are notified via
`ctx.session.send_resource_updated(uri)` instead.
"""
await self._bus.publish(ResourceUpdated(uri=str(uri)))
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
"""Read a resource by URI.

View file

@ -47,6 +47,12 @@ class PromptManager:
self._prompts[prompt.name] = prompt
return prompt
def remove_prompt(self, name: str) -> None:
"""Remove a prompt by name."""
if name not in self._prompts:
raise ValueError(f"Unknown prompt: {name}")
del self._prompts[name]
async def render_prompt(
self,
name: str,

View file

@ -88,6 +88,7 @@ from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.shared.uri_template import UriTemplate
@ -182,6 +183,7 @@ class MCPServer(Generic[LifespanResultT]):
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
request_state_security: RequestStateSecurity | None = None,
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
subscriptions: SubscriptionBus | None = None,
):
self._resource_security = resource_security
self.settings = Settings(
@ -201,6 +203,10 @@ class MCPServer(Generic[LifespanResultT]):
resources=resources, warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
)
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
# The subscriptions/listen fan-out seam (2026-07-28). The default bus is
# in-process; pass an `SubscriptionBus` implementation over an external pub/sub
# backend to fan events out across replicas.
self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus()
self._lowlevel_server = Server(
name=name or "mcp-server",
title=title,
@ -217,6 +223,7 @@ class MCPServer(Generic[LifespanResultT]):
on_list_resource_templates=self._handle_list_resource_templates,
on_list_prompts=self._handle_list_prompts,
on_get_prompt=self._handle_get_prompt,
on_subscriptions_listen=ListenHandler(self._subscriptions),
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server.
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
@ -396,7 +403,7 @@ class MCPServer(Generic[LifespanResultT]):
async def _handle_call_tool(
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
) -> CallToolResult | InputRequiredResult:
context = Context(request_context=ctx, mcp_server=self, input_params=params)
context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions)
try:
return await self.call_tool(params.name, params.arguments or {}, context)
except MCPError:
@ -412,7 +419,7 @@ class MCPServer(Generic[LifespanResultT]):
async def _handle_read_resource(
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
) -> ReadResourceResult | InputRequiredResult:
context = Context(request_context=ctx, mcp_server=self, input_params=params)
context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions)
try:
results = await self.read_resource(params.uri, context)
except ResourceNotFoundError as err:
@ -456,7 +463,7 @@ class MCPServer(Generic[LifespanResultT]):
async def _handle_get_prompt(
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
) -> GetPromptResult | InputRequiredResult:
context = Context(request_context=ctx, mcp_server=self, input_params=params)
context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions)
return await self.get_prompt(params.name, params.arguments, context)
async def list_tools(self) -> list[MCPTool]:
@ -481,7 +488,7 @@ class MCPServer(Generic[LifespanResultT]):
) -> CallToolResult | InputRequiredResult:
"""Call a tool by name with arguments."""
if context is None:
context = Context(mcp_server=self)
context = Context(mcp_server=self, subscriptions=self._subscriptions)
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
async def list_resources(self) -> list[MCPResource]:
@ -533,7 +540,7 @@ class MCPServer(Generic[LifespanResultT]):
ResourceError: If template creation or resource reading fails.
"""
if context is None:
context = Context(mcp_server=self)
context = Context(mcp_server=self, subscriptions=self._subscriptions)
resource = await self._resource_manager.get_resource(uri, context)
if isinstance(resource, InputRequiredResult):
return resource
@ -879,6 +886,17 @@ class MCPServer(Generic[LifespanResultT]):
"""
self._prompt_manager.add_prompt(prompt)
def remove_prompt(self, name: str) -> None:
"""Remove a prompt from the server by name.
Args:
name: The name of the prompt to remove
Raises:
ValueError: If the prompt does not exist
"""
self._prompt_manager.remove_prompt(name)
def prompt(
self,
name: str | None = None,
@ -1242,7 +1260,7 @@ class MCPServer(Generic[LifespanResultT]):
carrying the echoed opaque state.
"""
if context is None:
context = Context(mcp_server=self)
context = Context(mcp_server=self, subscriptions=self._subscriptions)
try:
prompt = self._prompt_manager.get_prompt(name)
if not prompt:

View file

@ -0,0 +1,296 @@
"""Server-side `subscriptions/listen` support (2026-07-28, SEP-2575).
On the 2026-07-28 wire there is no standing GET stream: a client opts in to
server events by sending a `subscriptions/listen` request whose response IS
the stream. This module provides the two pieces a server needs:
- `SubscriptionBus`: the pluggable fan-out seam. The bus carries typed `ServerEvent`
values, not wire notifications - the listen handler owns subscription-id
stamping and per-stream filtering, so a custom bus (e.g. backed by Redis
pub/sub for multi-replica deployments) never sees JSON-RPC. The in-process
default is `InMemorySubscriptionBus`.
- `ListenHandler`: the request handler that serves `subscriptions/listen`.
`MCPServer` registers one automatically; lowlevel `Server` users pass an
instance as `on_subscriptions_listen=`.
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
event kind the client did not request. Delivery is fire-and-forget with no
replay: a dropped stream is not resumable - clients re-listen and refetch.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Protocol
import anyio
import anyio.lowlevel
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
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.
Implement this over an external pub/sub backend (Redis, NATS, ...) to fan
events out across replicas: `publish` forwards the event to the backend,
and each replica's bus invokes its local listeners for events arriving
from the backend. The same instance can be shared across servers.
`publish` is async so backend implementations can do network I/O.
`subscribe` is synchronous local registration. Listeners are synchronous,
must not raise, and are invoked on the server's event loop.
"""
async def publish(self, event: ServerEvent) -> None:
"""Deliver `event` to every subscribed listener."""
...
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
"""Register `listener` and return an idempotent unsubscribe callable."""
...
class InMemorySubscriptionBus:
"""In-process `SubscriptionBus`: synchronous fan-out to listeners in subscription order."""
def __init__(self) -> None:
# Keyed by a per-subscription token so the same callable can be
# registered more than once (bound methods compare equal).
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
async def publish(self, event: ServerEvent) -> None:
"""Deliver `event` to every subscribed listener.
A raising listener is logged and skipped: one bad listener must not
starve the others or fail the publishing handler. Ends with a
checkpoint so a burst of publishes from one task lets listen streams
drain between events instead of overflowing their buffers unread.
"""
for listener in list(self._listeners.values()):
try:
listener(event)
except Exception: # fan-out boundary: isolate listeners from each other
logger.exception("subscription listener raised; continuing")
await anyio.lowlevel.checkpoint()
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
"""Register `listener` and return an idempotent unsubscribe callable."""
token = object()
self._listeners[token] = listener
def unsubscribe() -> None:
self._listeners.pop(token, None)
return unsubscribe
def _safe_unsubscribe(unsubscribe: Callable[[], None]) -> None:
"""Run a bus's unsubscribe callable, isolating the stream from it raising.
The callable comes from a custom `SubscriptionBus`; a raising one is
logged and skipped so it cannot stop the stream's own cleanup from
releasing its subscription slot.
"""
try:
unsubscribe()
except Exception: # fan-out boundary: a raising bus must not skip stream cleanup
logger.exception("bus unsubscribe raised; continuing stream cleanup")
def _honored_subset(requested: SubscriptionFilter) -> SubscriptionFilter:
"""The subset of `requested` the server will deliver, for the ack.
Every requested kind is honored - whether an event kind ever fires
depends on what the server publishes, exactly as a subscription to a
nonexistent resource URI is honored and never fires. Non-true flags and
an empty URI list are dropped rather than echoed as falsy values.
"""
return SubscriptionFilter(
tools_list_changed=True if requested.tools_list_changed else None,
prompts_list_changed=True if requested.prompts_list_changed else None,
resources_list_changed=True if requested.resources_list_changed else None,
resource_subscriptions=list(requested.resource_subscriptions) if requested.resource_subscriptions else None,
)
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.
Register on a lowlevel `Server` via `on_subscriptions_listen=` (or
`add_request_handler`); `MCPServer` does so automatically. Each call
acknowledges the honored filter first, then forwards matching bus events
onto the request's response stream until the client disconnects (which
cancels the handler; the stream just ends, per the spec's abrupt-close
contract) or `close` ends all streams gracefully.
Requires a transport that can stream a request's response (streamable
HTTP's SSE mode).
`max_subscriptions` bounds concurrent streams (further listen requests are
rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events`
bounds each stream's event backlog: a stream whose client has stopped
reading is ended at the cap (the client re-listens and refetches - there
is no replay, so ending the stream loses nothing the backlog wasn't
already losing).
"""
def __init__(self, bus: SubscriptionBus, *, max_subscriptions: int = 1024, max_buffered_events: int = 1024) -> None:
self._bus = bus
self._max_subscriptions = max_subscriptions
self._max_buffered_events = max_buffered_events
self._streams: set[anyio.streams.memory.MemoryObjectSendStream[ServerEvent]] = set()
async def __call__(
self,
ctx: ServerRequestContext[Any, Any],
params: SubscriptionsListenRequestParams,
) -> SubscriptionsListenResult:
"""Serve one listen stream."""
subscription_id = ctx.request_id
if subscription_id is None:
raise MCPError(INVALID_REQUEST, "subscriptions/listen requires a request id")
if len(self._streams) >= self._max_subscriptions:
raise MCPError(INTERNAL_ERROR, "Subscription limit reached")
honored = _honored_subset(params.notifications)
honored_uris = frozenset(honored.resource_subscriptions or ())
meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: subscription_id}
# Buffered so publishers don't block on a slow consumer (the transport
# write happens in this handler task, not the publisher's). A stream
# whose backlog hits the cap is ended - see the class docstring.
send, recv = anyio.create_memory_object_stream[ServerEvent](self._max_buffered_events)
def deliver(event: ServerEvent) -> None:
if _event_matches(honored, honored_uris, event):
try:
send.send_nowait(event)
except anyio.ClosedResourceError:
# `close` closed this stream; the loop below is unwinding.
pass
except anyio.WouldBlock:
logger.warning("listen stream %r backlog full; ending the stream", subscription_id)
# Release the subscription slot now: the handler's own
# cleanup can be wedged in a transport write that closing
# this buffer cannot wake (a client that stopped reading).
self._streams.discard(send)
send.close()
# Subscribe before sending the ack so an event published while the
# ack write is suspended is buffered rather than lost. The ack is
# still the first frame: this task alone writes the stream, and it
# only starts draining the buffer after the ack send returns.
unsubscribe = self._bus.subscribe(deliver)
self._streams.add(send)
try:
await ctx.session.send_notification(
SubscriptionsAcknowledgedNotification(
params=SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta)
),
related_request_id=subscription_id,
)
async for event in recv:
await ctx.session.send_notification(
_event_to_notification(event, meta), related_request_id=subscription_id
)
finally:
_safe_unsubscribe(unsubscribe)
self._streams.discard(send)
send.close()
recv.close()
return SubscriptionsListenResult(_meta=meta)
def close(self) -> None:
"""Initiate graceful closure of every open listen stream.
Each stream then drains its buffered events and sends its
`SubscriptionsListenResult` (stamped with the subscription id) as the
final frame from its own handler task - the spec's graceful closure
flow, telling clients the stream ended deliberately rather than
dropping. This method only initiates that; it does not wait for the
streams to finish flushing.
"""
for stream in list(self._streams):
stream.close()