Skip to content

Commit bfd473e

Browse files
committed
Make the subscription bus async and fix review findings
Review follow-ups to the subscriptions/listen runtime: - Rename EventBus to SubscriptionBus: it carries exactly the four subscription event kinds, and the generic name collided conceptually with the unrelated EventStore in the same package. - Make SubscriptionBus.publish async. Backend implementations (Redis, NATS) do network I/O on publish; a sync protocol would force them to block the loop or spawn their own tasks. This also makes the Context notify_* methods genuinely async, so they cannot be called from sync handlers (which run on worker threads where waking the listen streams is unsafe) - the illegal context is now unrepresentable instead of guarded. - Subscribe each listen stream to the bus before sending the ack: an event published while the ack write was suspended used to be lost after the client had been told the subscription was live. The ack is still the first frame - the handler task alone writes the stream and only drains the buffer after the ack send returns. - Register bus listeners under per-subscription tokens: equal callables (e.g. the same bound method subscribed twice) used to collapse in the set, so one unsubscribe silently detached both registrations. - Drop the stdio claim from ListenHandler's docstring (no 2026-era stdio serving exists yet) and document that resource-updated URIs are matched as exact strings and do not reach legacy resources/subscribe subscribers.
1 parent a68c7fc commit bfd473e

7 files changed

Lines changed: 147 additions & 69 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Each of these is one idea you now have the vocabulary for; each has its own chap
183183

184184
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**).
185185
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
186-
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass an `mcp.server.subscriptions.ListenHandler` built over an `EventBus` (the in-memory default, or your own — e.g. Redis-backed), keep the bus where your other handlers can reach it (the lifespan is a natural home), and publish `ServerEvent`s to it. The handler owns the wire semantics: ack-first, per-stream filtering, and subscription-id tagging.
186+
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass an `mcp.server.subscriptions.ListenHandler` built over a `SubscriptionBus` (the in-memory default, or your own — e.g. Redis-backed), keep the bus where your other handlers can reach it (the lifespan is a natural home), and publish `ServerEvent`s to it. The handler owns the wire semantics: ack-first, per-stream filtering, and subscription-id tagging.
187187
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
188188

189189
## Recap

docs/tutorial/context.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ What a server offers is not fixed at import time. Register a tool at runtime, th
104104

105105
The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource.
106106

107-
On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened. The `Context` publish methods — `ctx.notify_tools_changed()`, `ctx.notify_prompts_changed()`, `ctx.notify_resources_changed()`, and `ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once, and are synchronous (no `await`). Behind a load balancer, pass your own `EventBus` implementation as `MCPServer(subscriptions=...)` to fan events out across replicas; the in-process default covers a single server.
107+
On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened — the `send_*` methods above do not reach those streams. The `Context` publish methods — `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once. Behind a load balancer, pass your own `SubscriptionBus` implementation as `MCPServer(subscriptions=...)` to fan events out across replicas; the in-process default covers a single server.
108108

109109
!!! check
110110
Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it

src/mcp/server/mcpserver/context.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616
elicit_with_validation,
1717
)
1818
from mcp.server.lowlevel.helper_types import ReadResourceContents
19-
from mcp.server.subscriptions import PromptsListChanged, ResourcesListChanged, ResourceUpdated, ToolsListChanged
19+
from mcp.server.subscriptions import (
20+
PromptsListChanged,
21+
ResourcesListChanged,
22+
ResourceUpdated,
23+
ToolsListChanged,
24+
)
2025
from mcp.shared.exceptions import MCPDeprecationWarning
2126

2227
if TYPE_CHECKING:
@@ -110,21 +115,27 @@ async def report_progress(self, progress: float, total: float | None = None, mes
110115
"""
111116
await self.request_context.session.report_progress(progress, total, message)
112117

113-
def notify_tools_changed(self) -> None:
118+
async def notify_tools_changed(self) -> None:
114119
"""Publish a tools list-changed event to `subscriptions/listen` subscribers."""
115-
self.mcp_server.subscriptions.publish(ToolsListChanged())
120+
await self.mcp_server.subscriptions.publish(ToolsListChanged())
116121

117-
def notify_prompts_changed(self) -> None:
122+
async def notify_prompts_changed(self) -> None:
118123
"""Publish a prompts list-changed event to `subscriptions/listen` subscribers."""
119-
self.mcp_server.subscriptions.publish(PromptsListChanged())
124+
await self.mcp_server.subscriptions.publish(PromptsListChanged())
120125

121-
def notify_resources_changed(self) -> None:
126+
async def notify_resources_changed(self) -> None:
122127
"""Publish a resources list-changed event to `subscriptions/listen` subscribers."""
123-
self.mcp_server.subscriptions.publish(ResourcesListChanged())
128+
await self.mcp_server.subscriptions.publish(ResourcesListChanged())
129+
130+
async def notify_resource_updated(self, uri: str | AnyUrl) -> None:
131+
"""Publish a resource-updated event for `uri` to `subscriptions/listen` subscribers.
124132
125-
def notify_resource_updated(self, uri: str | AnyUrl) -> None:
126-
"""Publish a resource-updated event for `uri` to `subscriptions/listen` subscribers."""
127-
self.mcp_server.subscriptions.publish(ResourceUpdated(uri=str(uri)))
133+
The URI is matched as an exact string against each stream's filter.
134+
Reaches `subscriptions/listen` streams only; clients on earlier
135+
protocol versions that used `resources/subscribe` are notified via
136+
`ctx.session.send_resource_updated(uri)` instead.
137+
"""
138+
await self.mcp_server.subscriptions.publish(ResourceUpdated(uri=str(uri)))
128139

129140
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
130141
"""Read a resource by URI.

src/mcp/server/mcpserver/server.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
from mcp.server.stdio import stdio_server
8989
from mcp.server.streamable_http import EventStore
9090
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
91-
from mcp.server.subscriptions import EventBus, InMemoryEventBus, ListenHandler
91+
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
9292
from mcp.server.transport_security import TransportSecuritySettings
9393
from mcp.shared.exceptions import MCPError
9494
from mcp.shared.uri_template import UriTemplate
@@ -183,7 +183,7 @@ def __init__(
183183
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
184184
request_state_security: RequestStateSecurity | None = None,
185185
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
186-
subscriptions: EventBus | None = None,
186+
subscriptions: SubscriptionBus | None = None,
187187
):
188188
self._resource_security = resource_security
189189
self.settings = Settings(
@@ -204,9 +204,9 @@ def __init__(
204204
)
205205
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
206206
# The subscriptions/listen fan-out seam (2026-07-28). The default bus is
207-
# in-process; pass an `EventBus` implementation over an external pub/sub
207+
# in-process; pass an `SubscriptionBus` implementation over an external pub/sub
208208
# backend to fan events out across replicas.
209-
self._subscriptions: EventBus = subscriptions if subscriptions is not None else InMemoryEventBus()
209+
self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus()
210210
self._lowlevel_server = Server(
211211
name=name or "mcp-server",
212212
title=title,
@@ -293,7 +293,7 @@ def version(self) -> str | None:
293293
return self._lowlevel_server.version
294294

295295
@property
296-
def subscriptions(self) -> EventBus:
296+
def subscriptions(self) -> SubscriptionBus:
297297
"""The `subscriptions/listen` event bus.
298298
299299
Publish a `ServerEvent` here (or via the `Context.notify_*` methods)

src/mcp/server/subscriptions.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
server events by sending a `subscriptions/listen` request whose response IS
55
the stream. This module provides the two pieces a server needs:
66
7-
- `EventBus`: the pluggable fan-out seam. The bus carries typed `ServerEvent`
7+
- `SubscriptionBus`: the pluggable fan-out seam. The bus carries typed `ServerEvent`
88
values, not wire notifications - the listen handler owns subscription-id
99
stamping and per-stream filtering, so a custom bus (e.g. backed by Redis
1010
pub/sub for multi-replica deployments) never sees JSON-RPC. The in-process
11-
default is `InMemoryEventBus`.
11+
default is `InMemorySubscriptionBus`.
1212
- `ListenHandler`: the request handler that serves `subscriptions/listen`.
1313
`MCPServer` registers one automatically; lowlevel `Server` users pass an
1414
instance as `on_subscriptions_listen=`.
@@ -81,19 +81,20 @@ class ResourceUpdated:
8181
"""An event a server publishes for delivery to listen subscribers."""
8282

8383

84-
class EventBus(Protocol):
84+
class SubscriptionBus(Protocol):
8585
"""Fan-out seam between event publishers and open listen streams.
8686
8787
Implement this over an external pub/sub backend (Redis, NATS, ...) to fan
8888
events out across replicas: `publish` forwards the event to the backend,
8989
and each replica's bus invokes its local listeners for events arriving
9090
from the backend. The same instance can be shared across servers.
9191
92-
Both methods are synchronous and must be called from the server's event
93-
loop thread. Listeners must not raise.
92+
`publish` is async so backend implementations can do network I/O.
93+
`subscribe` is synchronous local registration. Listeners are synchronous,
94+
must not raise, and are invoked on the server's event loop.
9495
"""
9596

96-
def publish(self, event: ServerEvent) -> None:
97+
async def publish(self, event: ServerEvent) -> None:
9798
"""Deliver `event` to every subscribed listener."""
9899
...
99100

@@ -102,23 +103,26 @@ def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], Non
102103
...
103104

104105

105-
class InMemoryEventBus:
106-
"""In-process `EventBus`: synchronous fan-out to a set of listeners."""
106+
class InMemorySubscriptionBus:
107+
"""In-process `SubscriptionBus`: synchronous fan-out to listeners in subscription order."""
107108

108109
def __init__(self) -> None:
109-
self._listeners: set[Callable[[ServerEvent], None]] = set()
110+
# Keyed by a per-subscription token so the same callable can be
111+
# registered more than once (bound methods compare equal).
112+
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
110113

111-
def publish(self, event: ServerEvent) -> None:
114+
async def publish(self, event: ServerEvent) -> None:
112115
"""Deliver `event` to every subscribed listener."""
113-
for listener in list(self._listeners):
116+
for listener in list(self._listeners.values()):
114117
listener(event)
115118

116119
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
117120
"""Register `listener` and return an idempotent unsubscribe callable."""
118-
self._listeners.add(listener)
121+
token = object()
122+
self._listeners[token] = listener
119123

120124
def unsubscribe() -> None:
121-
self._listeners.discard(listener)
125+
self._listeners.pop(token, None)
122126

123127
return unsubscribe
124128

@@ -172,10 +176,10 @@ class ListenHandler:
172176
contract) or `close` ends all streams gracefully.
173177
174178
Requires a transport that can stream a request's response (streamable
175-
HTTP's SSE mode, stdio).
179+
HTTP's SSE mode).
176180
"""
177181

178-
def __init__(self, bus: EventBus) -> None:
182+
def __init__(self, bus: SubscriptionBus) -> None:
179183
self._bus = bus
180184
self._streams: set[anyio.streams.memory.MemoryObjectSendStream[ServerEvent]] = set()
181185

@@ -191,14 +195,6 @@ async def __call__(
191195
honored = _honored_subset(params.notifications)
192196
meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: subscription_id}
193197

194-
# Ack first, subscribe second: no event can precede the ack frame.
195-
await ctx.session.send_notification(
196-
SubscriptionsAcknowledgedNotification(
197-
params=SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta)
198-
),
199-
related_request_id=subscription_id,
200-
)
201-
202198
# Unbounded buffer so publishers never block on a slow consumer (the
203199
# transport write happens in this handler task, not the publisher's).
204200
send, recv = anyio.create_memory_object_stream[ServerEvent](math.inf)
@@ -208,12 +204,22 @@ def deliver(event: ServerEvent) -> None:
208204
try:
209205
send.send_nowait(event)
210206
except anyio.ClosedResourceError:
211-
# `aclose` closed this stream; the loop below is unwinding.
207+
# `close` closed this stream; the loop below is unwinding.
212208
pass
213209

210+
# Subscribe before sending the ack so an event published while the
211+
# ack write is suspended is buffered rather than lost. The ack is
212+
# still the first frame: this task alone writes the stream, and it
213+
# only starts draining the buffer after the ack send returns.
214214
unsubscribe = self._bus.subscribe(deliver)
215215
self._streams.add(send)
216216
try:
217+
await ctx.session.send_notification(
218+
SubscriptionsAcknowledgedNotification(
219+
params=SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta)
220+
),
221+
related_request_id=subscription_id,
222+
)
217223
async for event in recv:
218224
await ctx.session.send_notification(
219225
_event_to_notification(event, meta), related_request_id=subscription_id

tests/server/mcpserver/test_server.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from mcp.server.mcpserver.resources import FileResource, FunctionResource
5353
from mcp.server.mcpserver.utilities.types import Audio, Image
5454
from mcp.server.subscriptions import (
55-
InMemoryEventBus,
55+
InMemorySubscriptionBus,
5656
PromptsListChanged,
5757
ResourcesListChanged,
5858
ResourceUpdated,
@@ -2259,8 +2259,8 @@ async def probe(ctx: Context) -> str:
22592259

22602260

22612261
def test_subscriptions_bus_defaults_to_in_memory_and_accepts_custom() -> None:
2262-
assert isinstance(MCPServer().subscriptions, InMemoryEventBus)
2263-
bus = InMemoryEventBus()
2262+
assert isinstance(MCPServer().subscriptions, InMemorySubscriptionBus)
2263+
bus = InMemorySubscriptionBus()
22642264
assert MCPServer(subscriptions=bus).subscriptions is bus
22652265

22662266

@@ -2270,11 +2270,11 @@ async def test_context_notify_methods_publish_to_the_subscriptions_bus() -> None
22702270
mcp.subscriptions.subscribe(seen.append)
22712271

22722272
@mcp.tool()
2273-
def touch(ctx: Context) -> str:
2274-
ctx.notify_tools_changed()
2275-
ctx.notify_prompts_changed()
2276-
ctx.notify_resources_changed()
2277-
ctx.notify_resource_updated("r://x")
2273+
async def touch(ctx: Context) -> str:
2274+
await ctx.notify_tools_changed()
2275+
await ctx.notify_prompts_changed()
2276+
await ctx.notify_resources_changed()
2277+
await ctx.notify_resource_updated("r://x")
22782278
return "ok"
22792279

22802280
with anyio.fail_after(5):

0 commit comments

Comments
 (0)