Skip to content

Commit 04553f0

Browse files
committed
Add the subscriptions docs page and example story
- docs/advanced/subscriptions.md: publish-first narrative for MCPServer (ctx.notify_*), the filter/no-replay contract, the SubscriptionBus seam for multi-replica deployments, and the low-level composition. Snippets under docs_src/subscriptions/ with their claims proved by tests/docs_src/test_subscriptions.py; cross-linked from the context tutorial and the low-level server page. - examples/stories/subscriptions: promote the deferred stub to a runnable story (modern era, both transports, MCPServer and lowlevel variants). The client opens a listen stream through the session escape hatch, watches one URI and the tool list, observes exact-URI filtering and a runtime tool registration announcing itself, and closes the stream by cancelling the parked request.
1 parent bfd473e commit 04553f0

14 files changed

Lines changed: 546 additions & 21 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 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.
186+
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](subscriptions.md)** for the full composition.
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/advanced/subscriptions.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
Two more things the stream is *not*:
37+
38+
* **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.
39+
* **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.
40+
41+
## One process is the default. More takes a bus
42+
43+
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.
44+
45+
That seam is yours to implement: two methods over your pub/sub backend.
46+
47+
```python
48+
class RedisSubscriptionBus:
49+
async def publish(self, event: ServerEvent) -> None:
50+
await self.redis.publish("mcp-events", encode(event)) # to every replica
51+
52+
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
53+
... # register the local listener; a reader task calls it for arriving events
54+
```
55+
56+
```python
57+
mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...))
58+
```
59+
60+
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. The same instance is reachable as `mcp.subscriptions`, which is also how you publish from outside a request: `await mcp.subscriptions.publish(ToolsListChanged())`.
61+
62+
## The low-level composition
63+
64+
Down on the low-level `Server` there is no pre-wired anything — and the same parts assemble in three lines:
65+
66+
```python title="server.py" hl_lines="9 31 39"
67+
--8<-- "docs_src/subscriptions/tutorial002.py"
68+
```
69+
70+
* 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.
71+
* `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.
72+
* `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 and the client shouldn't re-listen. Without it, streams end when the client disconnects.
73+
74+
## Recap
75+
76+
* 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.
77+
* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free.
78+
* Streams receive only what their filter requested; URIs match exactly; nothing is replayed.
79+
* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`.
80+
* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument.

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 `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.
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. The whole story, including scaling out across replicas, is in **[Subscriptions](../advanced/subscriptions.md)**.
108108

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

docs_src/subscriptions/__init__.py

Whitespace-only changes.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from mcp.server.mcpserver import Context, MCPServer
2+
3+
mcp = MCPServer("Notebook")
4+
5+
NOTES = {"todo": "buy milk", "journal": "day one"}
6+
7+
8+
@mcp.resource("note://{name}")
9+
def note(name: str) -> str:
10+
return NOTES[name]
11+
12+
13+
@mcp.tool()
14+
async def edit_note(name: str, text: str, ctx: Context) -> str:
15+
NOTES[name] = text
16+
await ctx.notify_resource_updated(f"note://{name}")
17+
return "saved"
18+
19+
20+
def search(query: str) -> list[str]:
21+
return [name for name, text in NOTES.items() if query in text]
22+
23+
24+
@mcp.tool()
25+
async def enable_search(ctx: Context) -> str:
26+
mcp.add_tool(search)
27+
await ctx.notify_tools_changed()
28+
return "search is live"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from typing import Any
2+
3+
import mcp_types as types
4+
5+
from mcp.server.context import ServerRequestContext
6+
from mcp.server.lowlevel import Server
7+
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated
8+
9+
bus = InMemorySubscriptionBus()
10+
11+
NOTES = {"todo": "buy milk"}
12+
13+
EDIT_NOTE_SCHEMA: dict[str, Any] = {
14+
"type": "object",
15+
"properties": {"name": {"type": "string"}, "text": {"type": "string"}},
16+
"required": ["name", "text"],
17+
}
18+
19+
20+
async def list_tools(
21+
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
22+
) -> types.ListToolsResult:
23+
return types.ListToolsResult(
24+
tools=[types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA)]
25+
)
26+
27+
28+
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
29+
args = params.arguments or {}
30+
NOTES[args["name"]] = args["text"]
31+
await bus.publish(ResourceUpdated(uri=f"note://{args['name']}"))
32+
return types.CallToolResult(content=[types.TextContent(type="text", text="saved")])
33+
34+
35+
server = Server(
36+
"notebook",
37+
on_list_tools=list_tools,
38+
on_call_tool=call_tool,
39+
on_subscriptions_listen=ListenHandler(bus),
40+
)

examples/stories/manifest.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ lowlevel = false
6868
transports = ["in-memory", "http-asgi"]
6969
era = "dual-in-body"
7070

71+
[story.subscriptions]
72+
# subscriptions/listen exists only on the 2026 wire, so there is no legacy leg.
73+
# The listen request parks for the stream's lifetime; the client ends it by
74+
# cancelling the awaiting scope (the spec's client-side close).
75+
era = "modern"
76+
7177
[story.schema_validators]
7278

7379
[story.middleware]
@@ -166,7 +172,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
166172

167173
[deferred]
168174
caching = "client honouring + per-result override unlanded"
169-
subscriptions = "#2901 — Client.listen / ServerEventBus"
170175
tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
171176
skills = "#2896 — SEP-2640"
172177
events = "#2901 + #2896"
Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,58 @@
11
# subscriptions
22

3-
The 2026-era `subscriptions/listen` channel: the server publishes change events
4-
through a `ServerEventBus`, and `Client.listen()` opens an async iterator over
5-
them. Replaces the handshake-era `resources/subscribe` + standalone-GET
6-
notification path.
7-
8-
**Status: not yet implemented** ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)).
9-
The lowlevel registration surface is in this base —
10-
[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967)
11-
(`ae13ede`) added the lowlevel `on_subscriptions_listen` handler slot — but
12-
there is no `Client.listen()` or `ServerEventBus` yet. The runnable story is
13-
deliberately a follow-up PR to keep this one reviewable.
3+
Server-originated change notifications on the 2026-07-28 protocol. A client
4+
opens one `subscriptions/listen` request whose response **is** the stream; the
5+
server publishes with `ctx.notify_resource_updated(uri)` /
6+
`ctx.notify_tools_changed()` and the SDK does the wire work (ack-first,
7+
per-stream filtering, subscription-id tagging). Replaces the handshake-era
8+
`resources/subscribe` + standalone-GET notification path.
9+
10+
The client edits a note it did not subscribe to (silence), edits the one it
11+
did (a tagged `notifications/resources/updated`), registers a tool at runtime
12+
(`notifications/tools/list_changed`, then re-lists and calls it), and finally
13+
closes the stream by cancelling the parked request.
14+
15+
## Run it
16+
17+
```bash
18+
# HTTP — the client self-hosts the server on a free port, runs, then tears it
19+
# down (subscriptions/listen is 2026-era only)
20+
uv run python -m stories.subscriptions.client --http
21+
# same, against the lowlevel-API server variant
22+
uv run python -m stories.subscriptions.client --http --server server_lowlevel
23+
```
24+
25+
## What to look at
26+
27+
- `client.py` — stream frames arrive as ordinary server notifications via the
28+
constructor-only `message_handler=`. There is no client-side listen API yet,
29+
so opening the stream drops to the `client.session` escape hatch; the request
30+
parks for the stream's lifetime and the client closes the stream by
31+
cancelling it. Every frame's `_meta["io.modelcontextprotocol/subscriptionId"]`
32+
is the listen request's JSON-RPC id.
33+
- `server.py` — publishing is one `await ctx.notify_*()` line per change; the
34+
filter, the tagging, and the ack ordering are the SDK's job. Publishing with
35+
no subscribers is a no-op.
36+
- `server_lowlevel.py` — the same machinery held by hand: an
37+
`InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and
38+
`ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica
39+
deployment swaps the bus for one backed by its own pub/sub
40+
(`MCPServer(subscriptions=...)` on the high-level server).
41+
42+
## Caveats
43+
44+
- 2026-era only: on a 2025 connection the method does not exist (clients there
45+
use `resources/subscribe` and unsolicited notifications instead), so the
46+
story pins the modern era and has no legacy leg.
47+
- No replay: events published while no stream is open are not queued. The
48+
contract after a dropped stream is re-listen and re-fetch.
1449

1550
## Spec
1651

1752
[Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions)
1853

19-
## Working example elsewhere
20-
21-
The TypeScript SDK ships a runnable `subscriptions` story:
22-
[typescript-sdk/examples/subscriptions](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/subscriptions).
23-
2454
## See also
2555

26-
`standalone_get/` (handshake-era server-initiated notifications), `resources/`
27-
(legacy `subscribe` deliberately omitted).
56+
`streaming/` (request-scoped notifications), `events/` (the events extension
57+
on top of this channel, deferred), and `docs/advanced/subscriptions.md` (the
58+
narrative version).

examples/stories/subscriptions/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)