|
| 1 | +# Subscriptions |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +Your side of it is one line: publish the change. |
| 6 | + |
| 7 | +```python title="server.py" hl_lines="16 27" |
| 8 | +--8<-- "docs_src/subscriptions/tutorial001.py" |
| 9 | +``` |
| 10 | + |
| 11 | +* `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. |
| 12 | +* `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`. |
| 13 | +* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`, for the other two list-changed kinds. |
| 14 | +* 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. |
| 15 | + |
| 16 | +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. |
| 17 | + |
| 18 | +!!! check |
| 19 | + On the wire, a stream whose filter named `note://todo` looks like this after `edit_note` runs: |
| 20 | + |
| 21 | + ```json |
| 22 | + {"method": "notifications/subscriptions/acknowledged", |
| 23 | + "params": {"notifications": {"resourceSubscriptions": ["note://todo"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}} |
| 24 | + |
| 25 | + {"method": "notifications/resources/updated", |
| 26 | + "params": {"uri": "note://todo", "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}} |
| 27 | + ``` |
| 28 | + |
| 29 | + The acknowledgment echoes the filter the server agreed to honor, and every frame carries the |
| 30 | + listen request's JSON-RPC id under `_meta` — that id *is* the subscription id. |
| 31 | + |
| 32 | +## Only what was asked for |
| 33 | + |
| 34 | +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`. |
| 35 | + |
| 36 | +!!! warning |
| 37 | + Filters are honored without per-client authorization: any client may name any URI — |
| 38 | + including one it cannot read — and will receive update notifications for it (resource |
| 39 | + existence and change timing, never content). On a multi-tenant server, don't publish |
| 40 | + sensitive per-user URIs through `notify_resource_updated`, or serve the method with |
| 41 | + your own handler on the low-level `Server` and narrow the filter there before acking — |
| 42 | + the honored subset exists in the protocol precisely so servers can do this. |
| 43 | + |
| 44 | +Two more things the stream is *not*: |
| 45 | + |
| 46 | +* **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. |
| 47 | +* **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. |
| 48 | + |
| 49 | +## One process is the default. More takes a bus |
| 50 | + |
| 51 | +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. |
| 52 | + |
| 53 | +That seam is yours to implement: two methods over your pub/sub backend. |
| 54 | + |
| 55 | +```python |
| 56 | +class RedisSubscriptionBus: |
| 57 | + async def publish(self, event: ServerEvent) -> None: |
| 58 | + await self.redis.publish("mcp-events", encode(event)) # to every replica |
| 59 | + |
| 60 | + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: |
| 61 | + ... # register the local listener; a reader task calls it for arriving events |
| 62 | +``` |
| 63 | + |
| 64 | +```python |
| 65 | +mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...)) |
| 66 | +``` |
| 67 | + |
| 68 | +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. |
| 69 | + |
| 70 | +## The low-level composition |
| 71 | + |
| 72 | +Down on the low-level `Server` there is no pre-wired anything — and the same parts assemble in three lines: |
| 73 | + |
| 74 | +```python title="server.py" hl_lines="9 31 39" |
| 75 | +--8<-- "docs_src/subscriptions/tutorial002.py" |
| 76 | +``` |
| 77 | + |
| 78 | +* 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. |
| 79 | +* `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. |
| 80 | +* `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. |
| 81 | + |
| 82 | +## Recap |
| 83 | + |
| 84 | +* 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. |
| 85 | +* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free. |
| 86 | +* Streams receive only what their filter requested; URIs match exactly; nothing is replayed. |
| 87 | +* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`. |
| 88 | +* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument. |
0 commit comments