Skip to content

Commit 24297a1

Browse files
committed
Wire subscriptions/listen through MCPServer and the transports
MCPServer composes the listen runtime: subscriptions= (default on) and event_relay= constructor parameters, the hub exposed as mcp.subscriptions, notify_resource_updated on the server and on Context, and registry mutations (add/remove tool, add_resource, add_prompt, the new remove_prompt) publishing change events - only when the registry actually mutated; duplicate registrations no longer fan out phantom notifications. Relay pumps are owned by serving scopes, not connection lifespans: the streamable HTTP app enters hub.run() once per app (lowlevel streamable_http_app gained a composable lifespan parameter), sse_app and run_stdio_async enter it once per serving context, and in-process client connections never touch pumps. Overlapping serving contexts re-enter without owning, so dual-transport servers compose; previously a second concurrent SSE connection on a relay-equipped server failed at connect. publish() is now safe from sync tool bodies (anyio worker threads hop onto the event loop; nothing-armed publishes are no-ops from any context, so module-scope registration stays free), declared via a direct sniffio dependency. JSON response mode rejects listen only when a handler is registered; without one the request falls through to the honest METHOD_NOT_FOUND. Building a JSON-mode app with listen wired still fails at construction, with remedies ordered for lowlevel users first. The everything-server fixture gains diagnostic trigger tools that mutate the live registries and publish a watched-resource update, so the conformance subscription checks exercise the full cross-request path. Docs: migration guide entries (including NotificationOptions list-changed flags being superseded by listen-derived capabilities at 2026-07-28), low-level server page coverage for remove_request_handler and serving subscriptions/listen.
1 parent d198fbf commit 24297a1

38 files changed

Lines changed: 1549 additions & 84 deletions

docs/advanced/low-level-server.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
162162
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
163163
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
164164
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
165+
* `remove_request_handler(method)` undoes a registration: the method answers `METHOD_NOT_FOUND` again and no capability derives from it anymore. It removes the built-ins too — and does **not** restore any default, so removing `server/discover` leaves the server without one. Removing a method with no handler is a no-op.
165166

166167
One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC.
167168

@@ -177,6 +178,30 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
177178
!!! tip
178179
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**.
179180

181+
## Serving `subscriptions/listen`
182+
183+
`MCPServer` serves the 2026-07-28 subscription stream out of the box. Down here it is two pieces from `mcp.server.subscriptions`, composed by hand:
184+
185+
```python
186+
from mcp.server.subscriptions import SubscriptionHub, attach_listen
187+
188+
hub = SubscriptionHub()
189+
attach_listen(server, hub)
190+
```
191+
192+
`attach_listen` registers the SDK-owned `subscriptions/listen` handler — an ordinary request handler, through the same `add_request_handler` you just used. While it is registered, a 2026-07-28 `server/discover` advertises the `listChanged` flags and `resources.subscribe` for the families you serve; `detach_listen(server)` removes it again.
193+
194+
The hub is the publish surface, and nothing publishes automatically down here. You call it when something changes:
195+
196+
```python
197+
hub.notify_tools_changed() # also: notify_prompts_changed, notify_resources_changed
198+
hub.notify_resource_updated("bookshop://inventory")
199+
```
200+
201+
Both are synchronous and never block: every open stream whose filter matches receives the notification. Enter `async with hub.run():` around serving — a no-op for a plain hub, and the lifetime of the relay pumps when you construct `SubscriptionHub(relay=...)` with an `EventRelay`, the two-method protocol that fans opaque event bytes out across replicas over your broker (Redis pub/sub-shaped). `await hub.aclose()` ends every open stream gracefully: each parked handler flushes its pending events and returns its final result.
202+
203+
One constraint: a listen response is a stream, so `streamable_http_app(json_response=True)` raises `ValueError` while the handler is registered. Detach it — or don't attach — to serve JSON-only.
204+
180205
## The other handlers
181206

182207
Each of these is one idea you now have the vocabulary for; each has its own chapter.
@@ -192,7 +217,7 @@ Each of these is one idea you now have the vocabulary for; each has its own chap
192217
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
193218
* `_meta` on the result is addressed to the client application, not the model.
194219
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
195-
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
220+
* `add_request_handler(method, params_type, handler)` serves any method; `remove_request_handler(method)` stops serving one (no default comes back). `initialize` is reserved.
196221
* The capabilities a `Server` advertises are derived from which handlers you registered.
197222

198223
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.

docs/client/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
145145

146146
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
147147

148-
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)**).
148+
A client can also **subscribe** to a resource and be told when it changes. The legacy per-resource pair — `subscribe_resource(uri)` and `unsubscribe_resource(uri)` — is not served by `MCPServer`: the request is answered with an `MCPError`: `-32601`, *Method not found*. At protocol version 2026-07-28 resource subscriptions are delivered over the `subscriptions/listen` stream instead, which `MCPServer` serves out of the box (that is what `server_capabilities.resources.subscribe` advertises on a 2026 connection); a server that serves the legacy pair is built on the low-level `Server` (**[The low-level Server](../advanced/low-level-server.md)**).
149149

150150
## Prompts
151151

docs/migration.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,17 @@ For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may
431431

432432
On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).
433433

434+
### `MCPServer` serves `subscriptions/listen`; JSON response mode requires `subscriptions=False` ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575))
435+
436+
On protocol 2026-07-28, `MCPServer` serves the `subscriptions/listen` stream out of the box: registry mutations (`add_tool`, `remove_tool`, `add_resource`, `add_prompt`, and the decorators) automatically publish the matching list-changed notification to open streams, and `mcp.notify_resource_updated(uri)` / `ctx.notify_resource_updated(uri)` publish resource updates. The 2026 `server/discover` capabilities reflect this: the `listChanged` flags and `resources.subscribe` now derive from whether the listen runtime is wired, so a default `MCPServer` advertises all four (the 2025-era `initialize` capabilities are unchanged). Consequences to migrate around:
437+
438+
- A listen response is a stream, so `streamable_http_app(json_response=True)` and `run(transport="streamable-http", json_response=True)` now raise `ValueError` for a server with the listen runtime wired. Construct with `MCPServer(..., subscriptions=False)` (or serve SSE) — the server then advertises no subscription-delivered capabilities and answers `subscriptions/listen` with `-32601`.
439+
- Low-level `Server` users opt in explicitly: `attach_listen(server, SubscriptionHub())` from `mcp.server.subscriptions`. Multi-replica deployments pass an `EventRelay` (`MCPServer(event_relay=...)`) to fan events out across processes; without one, delivery is per-replica.
440+
- The lowlevel `NotificationOptions` knobs are ignored on 2026-07-28 connections: the `notification_options` passed to `Server.create_initialization_options()` / `get_capabilities()` only affect the legacy (≤2025-11-25) capability derivation. At 2026-07-28 the `listChanged` flags and `resources.subscribe` derive solely from whether a `subscriptions/listen` handler is registered.
441+
- With an `EventRelay` configured, the relay pumps run once per served app (the streamable-HTTP app, `sse_app()`, or a stdio run), for exactly as long as it serves. An in-process `Client(server)` is not a serving context and does not start them. Events published before serving starts are delivered to local streams only — never queued for the relay — so restarts produce no cross-replica replay.
442+
443+
The legacy `resources/subscribe` / `resources/unsubscribe` pair is removed from the 2026-07-28 wire; serving it for ≤2025-11-25 clients still requires registering handlers by hand — see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver).
444+
434445
### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))
435446

436447
`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
@@ -786,6 +797,8 @@ mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequest
786797

787798
`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly.
788799

800+
These handlers serve ≤2025-11-25 clients only: `logging/setLevel` and the subscribe pair are removed from the 2026-07-28 wire (`METHOD_NOT_FOUND` regardless of handlers), and resource updates are delivered over the `subscriptions/listen` stream instead — see [`MCPServer` serves `subscriptions/listen`](#mcpserver-serves-subscriptionslisten-json-response-mode-requires-subscriptionsfalse-sep-2575).
801+
789802
### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed
790803

791804
On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`.

docs/tutorial/first-steps.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ asyncio.run(main())
9797
```
9898

9999
```text
100-
{'prompts': {'list_changed': False}, 'resources': {'subscribe': False, 'list_changed': False}, 'tools': {'list_changed': False}}
100+
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
101101
```
102102

103103
That dictionary is the server's half of the handshake:
@@ -108,7 +108,7 @@ That dictionary is the server's half of the handshake:
108108
| `resources` | `resources/list`, `resources/templates/list`, `resources/read` |
109109
| `prompts` | `prompts/list`, `prompts/get` |
110110

111-
`MCPServer` serves all three primitives, so all three are always declared.
111+
`MCPServer` serves all three primitives, so all three are always declared. The `list_changed` and `subscribe` flags are `True` because `MCPServer` also serves the `subscriptions/listen` stream out of the box: a client can subscribe to list-change and resource-update notifications for each family.
112112

113113
Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](completions.md)** proves it.
114114

examples/servers/everything-server/mcp_everything_server/server.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import click
1717
from mcp.server import ServerRequestContext
1818
from mcp.server.mcpserver import Context, MCPServer
19-
from mcp.server.mcpserver.prompts.base import UserMessage
19+
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
2020
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
2121
from mcp.shared.exceptions import MCPError
2222
from mcp_types import (
@@ -96,6 +96,7 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event
9696
# Server state
9797
resource_subscriptions: set[str] = set()
9898
watched_resource_content = "Watched resource content"
99+
watched_resource_revision = 0
99100

100101
# Create event store for SSE resumability (SEP-1699)
101102
event_store = InMemoryEventStore()
@@ -603,6 +604,45 @@ async def test_reconnection(ctx: Context) -> str:
603604
return "Reconnection test completed"
604605

605606

607+
# Subscriptions/listen diagnostic hooks (SEP-2575). The conformance suite mutates
608+
# server lists exclusively through these tools; registry mutations auto-publish
609+
# list-changed notifications to matching open listen streams.
610+
def _dummy_tool() -> str:
611+
"""A dummy tool added and removed by test_trigger_tool_change."""
612+
return "dummy"
613+
614+
615+
@mcp.tool()
616+
def test_trigger_tool_change() -> str:
617+
"""Tests subscriptions/listen tools/list_changed delivery by mutating the tool list (SEP-2575)"""
618+
mcp.add_tool(_dummy_tool, name="test_dummy_tool")
619+
mcp.remove_tool("test_dummy_tool")
620+
return "Tool list changed"
621+
622+
623+
def _dummy_prompt() -> list[UserMessage]:
624+
"""A dummy prompt added and removed by test_trigger_prompt_change."""
625+
return [UserMessage(role="user", content=TextContent(type="text", text="dummy"))]
626+
627+
628+
@mcp.tool()
629+
def test_trigger_prompt_change() -> str:
630+
"""Tests subscriptions/listen prompts/list_changed delivery by mutating the prompt list (SEP-2575)"""
631+
mcp.add_prompt(Prompt.from_function(_dummy_prompt, name="test_dummy_prompt"))
632+
mcp.remove_prompt("test_dummy_prompt")
633+
return "Prompt list changed"
634+
635+
636+
@mcp.tool()
637+
def test_trigger_resource_update() -> str:
638+
"""Tests subscriptions/listen resources/updated delivery by updating test://watched-resource (SEP-2575)"""
639+
global watched_resource_content, watched_resource_revision
640+
watched_resource_revision += 1
641+
watched_resource_content = f"Watched resource content (revision {watched_resource_revision})"
642+
mcp.notify_resource_updated("test://watched-resource")
643+
return "Watched resource updated"
644+
645+
606646
# Resources
607647
@mcp.resource("test://static-text")
608648
def static_text_resource() -> str:

examples/snippets/servers/mcpserver_quickstart.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ def greet_user(name: str, style: str = "friendly") -> str:
3939

4040
# Run with streamable HTTP transport
4141
if __name__ == "__main__":
42-
mcp.run(transport="streamable-http", json_response=True)
42+
mcp.run(transport="streamable-http")

examples/snippets/servers/oauth_server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ async def verify_token(self, token: str) -> AccessToken | None:
1919
# Create MCPServer instance as a Resource Server
2020
mcp = MCPServer(
2121
"Weather Service",
22+
# JSON response mode (below) cannot carry the subscriptions/listen stream,
23+
# so construct without the listen runtime
24+
subscriptions=False,
2225
# Token verifier for authentication
2326
token_verifier=SimpleTokenVerifier(),
2427
# Auth settings for RFC 9728 Protected Resource Metadata

examples/snippets/servers/streamable_config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
from mcp.server.mcpserver import MCPServer
66

7-
mcp = MCPServer("StatelessServer")
7+
# JSON response mode (below) cannot carry the subscriptions/listen stream,
8+
# so construct without the listen runtime.
9+
mcp = MCPServer("StatelessServer", subscriptions=False)
810

911

1012
# Add a simple tool to demonstrate the server

examples/snippets/servers/streamable_http_basic_mounting.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111

1212
from mcp.server.mcpserver import MCPServer
1313

14-
# Create MCP server
15-
mcp = MCPServer("My App")
14+
# Create MCP server. JSON response mode (below) cannot carry the
15+
# subscriptions/listen stream, so construct without the listen runtime.
16+
mcp = MCPServer("My App", subscriptions=False)
1617

1718

1819
@mcp.tool()

examples/snippets/servers/streamable_http_host_mounting.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111

1212
from mcp.server.mcpserver import MCPServer
1313

14-
# Create MCP server
15-
mcp = MCPServer("MCP Host App")
14+
# Create MCP server. JSON response mode (below) cannot carry the
15+
# subscriptions/listen stream, so construct without the listen runtime.
16+
mcp = MCPServer("MCP Host App", subscriptions=False)
1617

1718

1819
@mcp.tool()

0 commit comments

Comments
 (0)