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
32 changed files with 1722 additions and 66 deletions

View file

@ -22,7 +22,4 @@
client: [] client: []
server: server: []
# SEP-2575 subscriptions/listen is not implemented yet; see the matching
# entry in expected-failures.yml for the full rationale.
- server-stateless

View file

@ -13,16 +13,6 @@
client: [] client: []
server: server:
# SEP-2575 subscriptions/listen is not implemented yet. The everything-
# server's legacy resources/subscribe handlers make it advertise
# `resources.subscribe` in server/discover, and as of conformance #372 a
# server that advertises a subscription capability but answers
# subscriptions/listen with -32601 fails the three listen MUST checks
# ("Not testable") instead of skipping them. Remove this entry when the
# listen runtime lands. NOTE: while listed, this entry also masks new
# failures in the scenario's other 25 (currently passing) checks — the
# baseline is per-scenario, not per-check.
- server-stateless
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
# tasks extension yet. These extension-tagged scenarios are selected only by # tasks extension yet. These extension-tagged scenarios are selected only by
# the bare `--suite all` leg — extension scenarios never match a # the bare `--suite all` leg — extension scenarios never match a

View file

@ -183,6 +183,7 @@ Each of these is one idea you now have the vocabulary for; each has its own chap
* `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)**). * `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)**).
* `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. * `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.
* `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.
* `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. * `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.
## Recap ## Recap

View file

@ -0,0 +1,88 @@
# 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.
Your side of it is one line: publish the change.
```python title="server.py" hl_lines="16 27"
--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.
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.
!!! check
On the wire, a stream whose filter named `note://todo` looks like this after `edit_note` runs:
```json
{"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": {"resourceSubscriptions": ["note://todo"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}}
{"method": "notifications/resources/updated",
"params": {"uri": "note://todo", "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}}
```
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.
## 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`.
!!! 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.
## One process is the default. More takes a bus
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.
That seam is yours to implement: two methods over your pub/sub backend.
```python
class RedisSubscriptionBus:
async def publish(self, event: ServerEvent) -> None:
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
```
```python
mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...))
```
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.
## The low-level composition
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"
--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.
## 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.

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`). `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 **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**[The low-level Server](../advanced/low-level-server.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 the server side of the story is **[Subscriptions](../advanced/subscriptions.md)**.
## Prompts ## Prompts

View file

@ -104,6 +104,8 @@ What a server offers is not fixed at import time. Register a tool at runtime, th
The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource. The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource.
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)**.
!!! check !!! check
Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it
anyway and the result is an error the model can read: anyway and the result is an error the model can read:

View file

@ -97,7 +97,7 @@ asyncio.run(main())
``` ```
```text ```text
{'prompts': {'list_changed': False}, 'resources': {'subscribe': False, 'list_changed': False}, 'tools': {'list_changed': False}} {'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
``` ```
That dictionary is the server's half of the handshake: That dictionary is the server's half of the handshake:

View file

View file

@ -0,0 +1,28 @@
from mcp.server.mcpserver import Context, MCPServer
mcp = MCPServer("Notebook")
NOTES = {"todo": "buy milk", "journal": "day one"}
@mcp.resource("note://{name}")
def note(name: str) -> str:
return NOTES[name]
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
NOTES[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
def search(query: str) -> list[str]:
return [name for name, text in NOTES.items() if query in text]
@mcp.tool()
async def enable_search(ctx: Context) -> str:
mcp.add_tool(search)
await ctx.notify_tools_changed()
return "search is live"

View file

@ -0,0 +1,40 @@
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated
bus = InMemorySubscriptionBus()
NOTES = {"todo": "buy milk"}
EDIT_NOTE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"name": {"type": "string"}, "text": {"type": "string"}},
"required": ["name", "text"],
}
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA)]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
args = params.arguments or {}
NOTES[args["name"]] = args["text"]
await bus.publish(ResourceUpdated(uri=f"note://{args['name']}"))
return types.CallToolResult(content=[types.TextContent(type="text", text="saved")])
server = Server(
"notebook",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_subscriptions_listen=ListenHandler(bus),
)

View file

@ -13,7 +13,7 @@ from typing import Annotated, Any
import click import click
from mcp.server import ServerRequestContext from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.server.mcpserver.prompts.base import UserMessage from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp_types import ( from mcp_types import (
@ -585,6 +585,34 @@ async def test_reconnection(ctx: Context) -> str:
return "Reconnection test completed" return "Reconnection test completed"
def _dynamic_tool() -> str:
"""A tool registered and removed by test_trigger_tool_change."""
return "dynamic"
def _dynamic_prompt() -> str:
"""A prompt registered and removed by test_trigger_prompt_change."""
return "dynamic"
@mcp.tool()
async def test_trigger_tool_change(ctx: Context) -> str:
"""Mutates the tool list and announces it to subscriptions/listen streams (SEP-2575)"""
mcp.add_tool(_dynamic_tool, name="test_dynamic_tool")
mcp.remove_tool("test_dynamic_tool")
await ctx.notify_tools_changed()
return "tool list changed"
@mcp.tool()
async def test_trigger_prompt_change(ctx: Context) -> str:
"""Mutates the prompt list and announces it to subscriptions/listen streams (SEP-2575)"""
mcp.add_prompt(Prompt.from_function(_dynamic_prompt, name="test_dynamic_prompt", description="dynamic"))
mcp.remove_prompt("test_dynamic_prompt")
await ctx.notify_prompts_changed()
return "prompt list changed"
# Resources # Resources
@mcp.resource("test://static-text") @mcp.resource("test://static-text")
def static_text_resource() -> str: def static_text_resource() -> str:

View file

@ -148,6 +148,7 @@ opens with a banner saying what replaces it.
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current | | [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy | | [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy |
| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy | | [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy |
| [`subscriptions`](subscriptions/) | `subscriptions/listen` streams: `ctx.notify_*`, `SubscriptionBus`, `ListenHandler` | current |
| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current | | [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current |
| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current | | [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current |
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current | | [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current |
@ -155,7 +156,6 @@ opens with a banner saying what replaces it.
| [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | current | | [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | current |
| **— deferred (README only) —** | | | | **— deferred (README only) —** | | |
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented | | [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
| [`subscriptions`](subscriptions/) | `subscriptions/listen`, `ServerEventBus`, `Client.listen()` | not yet implemented — [#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901) |
| [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented | | [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented |
| [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) | | [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
| [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) | | [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |

View file

@ -68,6 +68,12 @@ lowlevel = false
transports = ["in-memory", "http-asgi"] transports = ["in-memory", "http-asgi"]
era = "dual-in-body" era = "dual-in-body"
[story.subscriptions]
# subscriptions/listen exists only on the 2026 wire, so there is no legacy leg.
# The listen request parks for the stream's lifetime; the client ends it by
# cancelling the awaiting scope (the spec's client-side close).
era = "modern"
[story.schema_validators] [story.schema_validators]
[story.middleware] [story.middleware]
@ -166,7 +172,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
[deferred] [deferred]
caching = "client honouring + per-result override unlanded" caching = "client honouring + per-result override unlanded"
subscriptions = "#2901 — Client.listen / ServerEventBus"
tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
skills = "#2896 — SEP-2640" skills = "#2896 — SEP-2640"
events = "#2901 + #2896" events = "#2901 + #2896"

View file

@ -1,27 +1,60 @@
# subscriptions # subscriptions
The 2026-era `subscriptions/listen` channel: the server publishes change events Server-originated change notifications on the 2026-07-28 protocol. A client
through a `ServerEventBus`, and `Client.listen()` opens an async iterator over opens one `subscriptions/listen` request whose response **is** the stream; the
them. Replaces the handshake-era `resources/subscribe` + standalone-GET server publishes with `ctx.notify_resource_updated(uri)` /
notification path. `ctx.notify_tools_changed()` and the SDK does the wire work (ack-first,
per-stream filtering, subscription-id tagging). Replaces the handshake-era
`resources/subscribe` + standalone-GET notification path.
**Status: not yet implemented** ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). The client edits a note it did not subscribe to (silence), edits the one it
The lowlevel registration surface is in this base — did (a tagged `notifications/resources/updated`), registers a tool at runtime
[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) (`notifications/tools/list_changed`, then re-lists and calls it), and finally
(`ae13ede`) added the lowlevel `on_subscriptions_listen` handler slot — but stops listening - cancelling the parked request releases the local task, and
there is no `Client.listen()` or `ServerEventBus` yet. The runnable story is closing the connection ends the stream server-side.
deliberately a follow-up PR to keep this one reviewable.
## Run it
```bash
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# down (subscriptions/listen is 2026-era only)
uv run python -m stories.subscriptions.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.subscriptions.client --http --server server_lowlevel
```
## What to look at
- `client.py` — stream frames arrive as ordinary server notifications via the
constructor-only `message_handler=`. There is no client-side listen API yet,
so opening the stream drops to the `client.session` escape hatch; the request
parks for the stream's lifetime. Cancelling it releases the local task; over
HTTP the server-side stream ends when the connection closes. Every frame's
`_meta["io.modelcontextprotocol/subscriptionId"]` is the listen request's
JSON-RPC id.
- `server.py` — publishing is one `await ctx.notify_*()` line per change; the
filter, the tagging, and the ack ordering are the SDK's job. Publishing with
no subscribers is a no-op.
- `server_lowlevel.py` — the same machinery held by hand: an
`InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and
`ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica
deployment swaps the bus for one backed by its own pub/sub
(`MCPServer(subscriptions=...)` on the high-level server).
## Caveats
- 2026-era only: on a 2025 connection the method does not exist (clients there
use `resources/subscribe` and unsolicited notifications instead), so the
story pins the modern era and has no legacy leg.
- No replay: events published while no stream is open are not queued. The
contract after a dropped stream is re-listen and re-fetch.
## Spec ## Spec
[Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) [Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions)
## Working example elsewhere
The TypeScript SDK ships a runnable `subscriptions` story:
[typescript-sdk/examples/subscriptions](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/subscriptions).
## See also ## See also
`standalone_get/` (handshake-era server-initiated notifications), `resources/` `streaming/` (request-scoped notifications), `events/` (the events extension
(legacy `subscribe` deliberately omitted). on top of this channel, deferred), and `docs/advanced/subscriptions.md` (the
narrative version).

View file

@ -0,0 +1,97 @@
"""Open a `subscriptions/listen` stream, watch one URI and the tool list, then close it."""
import anyio
import mcp_types as types
from mcp.client import Client
from stories._harness import Target, run_client
SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId"
async def main(target: Target, *, mode: str = "auto") -> None:
# Stream frames arrive as ordinary server notifications; `message_handler`
# is constructor-only on `Client`, so the list it fills exists first.
received: list[types.ServerNotification] = []
arrival = anyio.Event()
async def on_message(message: object) -> None:
nonlocal arrival
if isinstance(
message,
types.SubscriptionsAcknowledgedNotification
| types.ResourceUpdatedNotification
| types.ToolListChangedNotification,
):
received.append(message)
arrival.set()
arrival = anyio.Event()
async def wait_for(count: int) -> None:
with anyio.fail_after(10):
while len(received) < count:
await arrival.wait()
async with Client(target, mode=mode, message_handler=on_message) as client:
before = await client.list_tools()
assert "search" not in {tool.name for tool in before.tools}
async with anyio.create_task_group() as tg:
# There is no client-side listen API yet, so the story drops to the
# `client.session` escape hatch. The request parks for the stream's
# lifetime, so it runs as a task; cancelling it releases the local
# awaiting scope. In-memory that also ends the server's stream; over
# HTTP today nothing aborts the POST, so the server-side stream ends
# when the connection closes (the `Client` exit right below).
async def listen() -> None:
request = types.SubscriptionsListenRequest(
params=types.SubscriptionsListenRequestParams(
notifications=types.SubscriptionFilter(
tools_list_changed=True, resource_subscriptions=["note://todo"]
)
)
)
await client.session.send_request(request, types.SubscriptionsListenResult)
tg.start_soon(listen)
# ── the ack is the first frame: it echoes the honored filter, tagged ──
await wait_for(1)
ack = received[0]
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification), ack
assert ack.params.notifications.tools_list_changed is True
assert ack.params.notifications.resource_subscriptions == ["note://todo"]
assert ack.params.meta is not None and SUBSCRIPTION_ID in ack.params.meta
# ── exact-URI filtering: an unsubscribed note edit stays silent ──
await client.call_tool("edit_note", {"name": "journal", "text": "day two"})
# ── the subscribed URI delivers, carrying the same subscription id ──
await client.call_tool("edit_note", {"name": "todo", "text": "water plants"})
await wait_for(2)
updated = received[1]
assert isinstance(updated, types.ResourceUpdatedNotification), updated
assert updated.params.uri == "note://todo"
assert updated.params.meta is not None
assert updated.params.meta[SUBSCRIPTION_ID] == ack.params.meta[SUBSCRIPTION_ID]
assert len(received) == 2, "the journal edit must not have been delivered"
# ── a runtime tool registration announces itself ──
await client.call_tool("enable_search", {})
await wait_for(3)
assert isinstance(received[2], types.ToolListChangedNotification), received[2]
# The client is done listening: cancel the parked request and let
# the connection teardown below end the stream server-side.
tg.cancel_scope.cancel()
# list_changed told us to re-fetch - the new tool is callable, and the
# session outlives the closed stream.
tools = await client.list_tools()
assert "search" in {tool.name for tool in tools.tools}
result = await client.call_tool("search", {"query": "water"})
content = result.content[0]
assert isinstance(content, types.TextContent) and content.text == "todo", result
if __name__ == "__main__":
run_client(main)

View file

@ -0,0 +1,41 @@
"""A notebook whose edits and tool changes reach `subscriptions/listen` streams."""
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
mcp = MCPServer("subscriptions-example")
notes = {"todo": "buy milk", "journal": "day one"}
@mcp.resource("note://{name}")
def note(name: str) -> str:
return notes[name]
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
"""Replace a note's text and tell subscribers that URI changed."""
notes[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
def search(query: str) -> list[str]:
return [name for name, text in notes.items() if query in text]
enabled = False
@mcp.tool()
async def enable_search(ctx: Context) -> str:
"""Register the `search` tool at runtime and tell subscribers the list changed."""
nonlocal enabled
if not enabled:
enabled = True
mcp.add_tool(search)
await ctx.notify_tools_changed()
return "search is live"
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)

View file

@ -0,0 +1,72 @@
"""The same notebook against the low-level Server: an explicit bus + ListenHandler."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import (
InMemorySubscriptionBus,
ListenHandler,
ResourceUpdated,
ToolsListChanged,
)
from stories._hosting import run_server_from_args
EDIT_NOTE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"name": {"type": "string"}, "text": {"type": "string"}},
"required": ["name", "text"],
}
EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}}
SEARCH_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
def build_server() -> Server[Any]:
# The bus lives wherever your handlers can reach it; the lifespan is the
# natural home in a bigger app. The closure is enough here.
bus = InMemorySubscriptionBus()
notes = {"todo": "buy milk", "journal": "day one"}
search_enabled = False
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
tools = [
types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA),
types.Tool(name="enable_search", description="Register the search tool.", input_schema=EMPTY_SCHEMA),
]
if search_enabled:
tools.append(types.Tool(name="search", description="Find notes.", input_schema=SEARCH_SCHEMA))
return types.ListToolsResult(tools=tools)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
nonlocal search_enabled
args = params.arguments or {}
if params.name == "edit_note":
notes[args["name"]] = args["text"]
await bus.publish(ResourceUpdated(uri=f"note://{args['name']}"))
return types.CallToolResult(content=[types.TextContent(text="saved")])
if params.name == "enable_search":
search_enabled = True
await bus.publish(ToolsListChanged())
return types.CallToolResult(content=[types.TextContent(text="search is live")])
assert params.name == "search" and search_enabled
matches = [name for name, text in notes.items() if args["query"] in text]
return types.CallToolResult(content=[types.TextContent(text=", ".join(matches))])
return Server(
"subscriptions-example",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_subscriptions_listen=ListenHandler(bus),
)
if __name__ == "__main__":
run_server_from_args(build_server)

View file

@ -44,6 +44,7 @@ nav:
- URI templates: advanced/uri-templates.md - URI templates: advanced/uri-templates.md
- Pagination: advanced/pagination.md - Pagination: advanced/pagination.md
- Caching hints: advanced/caching.md - Caching hints: advanced/caching.md
- Subscriptions: advanced/subscriptions.md
- Middleware: advanced/middleware.md - Middleware: advanced/middleware.md
- Extensions: advanced/extensions.md - Extensions: advanced/extensions.md
- MCP Apps: advanced/apps.md - MCP Apps: advanced/apps.md

View file

@ -383,6 +383,13 @@ async def handle_modern_request(
await _write(rej, scope, receive, send) await _write(rej, scope, receive, send)
return 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()) duplicated = find_duplicated_routing_header(request.headers.items())
if duplicated is not None: if duplicated is not None:
# The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping. # 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), 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( msg = await _to_jsonrpc_response(
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state) req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
) )

Some files were not shown because too many files have changed in this diff Show more