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

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

View file

@ -53,7 +53,7 @@ One rule sits above `"use"`: **calls carrying `meta` always reach the server.**
To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.
One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`.

View file

@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**.
A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page.
## Prompts

View file

@ -0,0 +1,86 @@
# Subscriptions
A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for.
This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there.
## Watching the stream
A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts.
```python title="client.py" hl_lines="16 19 29"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Iteration yields four typed events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`.
An event says *what* changed, never *how*. That is why `follow_board` calls `read_resource` and `list_tools`: the event is a cue to refetch. Read `event.uri` rather than assuming which resource moved: a filter can name several URIs, and a server may report a change on a sub-resource of one of them.
Duplicate events waiting to be consumed collapse into one, and refetching still gets you the current state. Only identical events collapse: two `ResourceUpdated` for different URIs are two events.
Two more properties of the handle:
* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire.
* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id.
## Watching without blocking
`follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current.
Open the subscription first, then start the watcher and get on with your work.
=== "asyncio"
```python title="app.py" hl_lines="18 20"
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
```
=== "trio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
```
=== "anyio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
```
!!! note
`app.py` imports `BOARD` and `read_board` from the first example, which this repo stores as
`tutorial003.py`. If you save the rendered files side by side as `client.py` and `app.py`,
write `from client import BOARD, read_board` instead. The `watch.py` example further down
imports `read_board` the same way.
The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one.
Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI.
To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.
## Streams end
A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`.
The difference is diagnostic, not a difference in what to do next: the stream is gone, nothing was replayed, and a watcher that still cares re-listens and refetches.
```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```
Servers close streams gracefully for their own reasons, including shedding a subscriber whose backlog grew too large, so a clean end is not a signal to stop watching. Back off before re-listening.
`SubscriptionLost` has one local cause too. The client holds at most 1024 unconsumed events, and a consumer that falls that far behind loses the subscription rather than grow without bound. Keep the body of the `async for` short and do slow work elsewhere.
`keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals.
## Recap
* Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed.
* Iterate with `async for event in sub`. Events are cues to refetch, never payloads.
* Open the subscription, then run the watcher as a task, and tool calls keep flowing beside it.
* A clean end stops the loop; a drop raises `SubscriptionLost`. Either way: re-listen, refetch, back off first.
* Leaving the block is the unsubscribe.
Publishing these events, narrowing the filter, and scaling past one process are the server's story: **[Subscriptions](../handlers/subscriptions.md)**. These same events also keep a client-side cache honest, and **[Caching](caching.md)** is the next page.

View file

@ -1,94 +1,146 @@
# Subscriptions
A server's catalog is not fixed. Tools get registered at runtime, resources change behind their URIs. The client side of that story is a subscription: on the 2026-07-28 protocol, a client that wants to hear about changes sends one `subscriptions/listen` request, and the response to that request *is* the stream — it stays open, carrying exactly the notification kinds the client asked for.
A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes.
**Subscriptions** are how a client hears about it. The client sends one `subscriptions/listen` request, and the response to that request *is* the stream: it stays open and carries the change notifications the client asked for.
## Publish it from the tool
Your side of it is one line: publish the change.
```python title="server.py" hl_lines="16 27"
```python title="server.py" hl_lines="20 32"
--8<-- "docs_src/subscriptions/tutorial001.py"
```
* `await ctx.notify_resource_updated("note://todo")` delivers `notifications/resources/updated` to every open listen stream that subscribed to that URI. Not to anyone else.
* `await ctx.notify_tools_changed()` delivers `notifications/tools/list_changed` to every stream that asked for tool-list changes. A client that receives it calls `tools/list` again — and now sees `search`.
* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`, for the other two list-changed kinds.
* No subscribers, no work: publishing to an idle server is a no-op. You don't check whether anyone is listening; you state what changed.
* `await ctx.notify_resource_updated("board://sprint")` reaches every open stream that subscribed to that URI. Nobody else.
* `await ctx.notify_tools_changed()` reaches every stream that asked for tool-list changes. A client that receives it calls `tools/list` again, and now sees `sprint_report`.
* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`.
* No subscribers, no work. Publishing to an idle server is a no-op, so you never check whether anyone is listening. You state what changed.
The SDK serves `subscriptions/listen` for you — `MCPServer` registers the handler at construction, and the wire obligations (the acknowledgment as the first frame, the per-stream filtering, the subscription id tagged onto every frame) are its job, not yours.
`MCPServer` serves `subscriptions/listen` for you. The wire obligations (the acknowledgment as the first frame, per-stream filtering, the subscription id on every frame) are the SDK's job.
!!! check
On the wire, a stream whose filter named `note://todo` looks like this after `edit_note` runs:
On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs:
```json
{"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": {"resourceSubscriptions": ["note://todo"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}}
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
{"method": "notifications/resources/updated",
"params": {"uri": "note://todo", "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}}
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
```
The acknowledgment echoes the filter the server agreed to honor, and every frame carries the
listen request's JSON-RPC id under `_meta` — that id *is* the subscription id.
Note what the update does *not* carry: the board. Every frame carries the listen request's JSON-RPC id under `_meta`, and that id is the subscription id. The client mints it: the Python `Client` uses strings like `"listen-1"`; other clients may use integers.
## Only what was asked for
The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else — publish a prompt change and that stream stays silent. Resource URIs are matched as exact strings: `note://todo` does not cover `note://todo/draft`.
The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else. Publish a prompt change and that stream stays silent.
`MCPServer` matches resource URIs as exact strings, so a stream that named `board://sprint` hears nothing about `board://sprint/tasks/1`. The spec lets a server report a change on a sub-resource of a subscribed URI; `MCPServer` never does, but clients are built to expect it.
Two things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch.
* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only.
!!! warning
Filters are honored without per-client authorization: any client may name any URI —
including one it cannot read — and will receive update notifications for it (resource
existence and change timing, never content). On a multi-tenant server, don't publish
sensitive per-user URIs through `notify_resource_updated`, or serve the method with
your own handler on the low-level `Server` and narrow the filter there before acking —
the honored subset exists in the protocol precisely so servers can do this.
Two more things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about.
* **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only.
Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant
server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure
is narrow but real: a subscriber learns that a URI it can guess changed, and when. It never
learns content, and it cannot probe what exists, because an unknown URI is honored too and
simply never fires. To narrow the filter per client today, serve the method with your own
handler on the low-level `Server` and acknowledge a smaller filter than the client asked
for; the acknowledgment is how the client learns what it actually got.
!!! warning "Streamable HTTP only, for now"
`subscriptions/listen` is served on the streamable-HTTP transport. Over stdio (and other
stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the
open-stream semantics haven't been built for that transport yet, even though
`server/discover` still advertises the subscription capabilities there.
`subscriptions/listen` needs a transport that can stream a request's response, which today
means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with
METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities
there. Serving it over stdio is planned; the open-stream semantics for that transport are
not built yet.
## One process is the default. More takes a bus
## The client end
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
Here is a client on the other side of that stream, following the board:
```python title="client.py" hl_lines="16"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Entering `client.listen(...)` sends the request and waits for your acknowledgment, so the stream is live when the block starts, and each typed event is a cue to refetch, never a payload. That is the whole contract in one screen. Everything else about the client end lives on its own page: watching beside a main flow, stream endings, and re-listening. See **[Subscriptions](../client/subscriptions.md)** under *Clients*.
## Scaling past one process
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer, because then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
That seam is yours to implement: two methods over your pub/sub backend.
```python
from collections.abc import Callable
from redis.asyncio import Redis
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
class RedisSubscriptionBus:
def __init__(self, redis: Redis) -> None:
self._redis = redis
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
async def publish(self, event: ServerEvent) -> None:
await self.redis.publish("mcp-events", encode(event)) # to every replica
await self._redis.publish("mcp-events", encode(event)) # to every replica
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
... # register the local listener; a reader task calls it for arriving events
token = object()
self._listeners[token] = listener
def unsubscribe() -> None:
self._listeners.pop(token, None)
return unsubscribe
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
```
`encode` is yours, and so is the reader task on each replica that decodes arriving messages and calls every registered listener. Listeners are synchronous, must not raise, and run on the server's event loop.
The bus carries typed `ServerEvent` values, four small dataclasses, never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol. It can only move events between processes.
To publish from outside a request, construct the bus yourself so you hold the reference. `MCPServer` builds one internally when you pass nothing, and does not expose it.
```python
mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...))
```
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
The bus carries typed `ServerEvent` values — four small dataclasses — never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol; it can only move events between processes. To publish from outside a request, keep a reference to the bus you constructed and `await bus.publish(ToolsListChanged())` — the server holds the same instance.
bus = InMemorySubscriptionBus()
mcp = MCPServer("Sprint Board", subscriptions=bus)
async def tools_reloaded() -> None:
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
```
## The low-level composition
Down on the low-level `Server` there is no pre-wired anything — and the same parts assemble in three lines:
Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines:
```python title="server.py" hl_lines="9 31 39"
```python title="server.py" hl_lines="9-10 48"
--8<-- "docs_src/subscriptions/tutorial002.py"
```
* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it — module scope here, the lifespan in a bigger app.
* `ListenHandler(bus)` is the same handler `MCPServer` registers; `on_subscriptions_listen=` is an ordinary handler slot. Don't want the SDK's semantics? Write your own handler for the slot — the spec obligations come with it.
* `ListenHandler.close()` gracefully ends every open stream: each one receives the listen request's result as its final frame, the spec's signal that the server ended the subscription deliberately — a clean end, as opposed to the abrupt drop a client may treat as a cue to reconnect. Without it, streams end when the client disconnects.
* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it: module scope here, the lifespan in a bigger app.
* `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter.
* `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects.
## Recap
* A client opts in with one `subscriptions/listen` request; the response is the stream. There is nothing to configure server-side — serving it is built in.
* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free.
* Streams receive only what their filter requested; URIs match exactly; nothing is replayed.
* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`.
* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument.
* A client opts in with one `subscriptions/listen` request, and the response is the stream. Serving it is built in.
* You publish with `ctx.notify_*`, and the SDK does the stamping, filtering, and lifecycle work.
* Events are cues, not payloads. Both ends refetch.
* The client end is `async with client.listen(...)`: **[Subscriptions](../client/subscriptions.md)** under *Clients* is that story.
* On the low-level `Server` you assemble the same parts yourself: a bus, `ListenHandler(bus)`, the `on_subscriptions_listen` slot.
* Scaling out means implementing `SubscriptionBus`, two methods, and passing it as `MCPServer(subscriptions=...)`.
Running the server that serves all this, behind one replica or twenty, is **[Deploy & scale](../run/deploy.md)**.

View file

@ -2055,6 +2055,23 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
## Deprecations
### Client resource-subscription methods deprecated (SEP-2575)
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`:
- `Client.subscribe_resource()` / `Client.unsubscribe_resource()`
- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()`
They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver:
```python
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
async for event in sub: # ResourceUpdated(uri="board://sprint")
...
```
See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`).
### Roots, Sampling, and Logging methods deprecated (SEP-2577)
[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier.

View file

@ -149,7 +149,7 @@ The seam between the two is the `SubscriptionBus`. Whatever bus you give a serve
Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment:
* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract.
* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#scaling-past-one-process)** has the sketch and the contract.
* The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes.
* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same.

View file

@ -190,9 +190,9 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and
### Change notifications become one stream
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. Two honest caveats as of `2.0.0b1`: the Python `Client` cannot open the listen stream yet (the driver ships in a later pre-release), and over stdio the server does not serve it. The net for a Python *client* on that release is that nothing delivers change notifications on a 2026-07-28 connection; a host that relies on `resources/updated` should connect with `mode="legacy"` until the driver lands.
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet.
**[Subscriptions](handlers/subscriptions.md)** on the server, and **[Deploy & scale](run/deploy.md)** for the bus.
**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus.
### The rest, quickly