Visitar URL original
Let MCPServer opt out of serving subscriptions/listen (#3626) · modelcontextprotocol/python-sdk@6513955 · GitHub
Skip to content

Commit 6513955

Browse files
authored
Let MCPServer opt out of serving subscriptions/listen (#3626)
1 parent 19e4f2a commit 6513955

7 files changed

Lines changed: 182 additions & 10 deletions

File tree

‎docs/handlers/subscriptions.md‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Your side of it is one line: publish the change.
1717
* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`.
1818
* 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.
1919

20-
`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.
20+
`MCPServer` serves `subscriptions/listen` for you, unless you [turn it off](#turning-it-off). The wire obligations (the acknowledgment as the first frame, per-stream filtering, the subscription id on every frame) are the SDK's job.
2121

2222
!!! check
2323
On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs:
@@ -74,7 +74,32 @@ Entering `client.listen(...)` sends the request and waits for your acknowledgmen
7474

7575
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.
7676

77-
That seam is yours to implement: two methods over your pub/sub backend.
77+
With the default bus it can't, because every replica has its own:
78+
79+
```mermaid
80+
flowchart LR
81+
client[Client] --> lb[Load balancer]
82+
lb --> stream
83+
lb ~~~~ gap
84+
lb --> tool
85+
subgraph B [Replica B]
86+
tool[tools/call] -- publishes --> busB[(bus B)]
87+
end
88+
gap[(no shared bus)]
89+
subgraph A [Replica A]
90+
stream[listen stream] -- subscribed --> busA[(bus A)]
91+
end
92+
style A fill:none
93+
style B fill:none
94+
style gap fill:none,stroke-dasharray:4 4
95+
```
96+
97+
Nothing fails: the call succeeds, and the stream stays silent. So behind a load balancer, pick one:
98+
99+
* **You need change notifications.** Give every replica the same bus, below.
100+
* **You don't.** [Turn them off](#turning-it-off), so no client is promised events it will miss or holds a stream open for them.
101+
102+
The shared bus is yours to implement: two methods over your pub/sub backend.
78103

79104
```python
80105
from collections.abc import Callable
@@ -123,6 +148,20 @@ async def tools_reloaded() -> None:
123148
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
124149
```
125150

151+
## Turning it off
152+
153+
A server whose catalog never changes has nothing to publish. Say so when you build it:
154+
155+
```python title="server.py" hl_lines="3"
156+
--8<-- "docs_src/subscriptions/tutorial007.py"
157+
```
158+
159+
* A `2026-07-28` client sees no change notifications advertised, and a `subscriptions/listen` request gets *Method not found* instead of an open stream.
160+
* `ctx.notify_*` still works and reaches nobody, so your handlers don't change.
161+
* Clients on earlier protocol versions see no difference.
162+
163+
An open stream is a request that never finishes, so this also matters on a host that bills by request duration.
164+
126165
## The low-level composition
127166

128167
Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines:
@@ -143,5 +182,6 @@ Down on the low-level `Server` there is no pre-wired anything, and the same part
143182
* The client end is `async with client.listen(...)`: **[Subscriptions](../client/subscriptions.md)** under *Clients* is that story.
144183
* On the low-level `Server` you assemble the same parts yourself: a bus, `ListenHandler(bus)`, the `on_subscriptions_listen` slot.
145184
* Scaling out means implementing `SubscriptionBus`, two methods, and passing it as `MCPServer(subscriptions=...)`.
185+
* Nothing to publish, or replicas with no shared bus: `MCPServer(subscriptions=False)` advertises no change notifications and holds no stream.
146186

147187
Running the server that serves all this, behind one replica or twenty, is **[Deploy & scale](../run/deploy.md)**.

‎docs/run/deploy.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ Nothing about the fan-out cares which server object a stream is attached to. Two
168168
* 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.
169169
* 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.
170170
* 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.
171+
* A server that needs no change notifications skips the bus: **[turn them off](../handlers/subscriptions.md#turning-it-off)**.
171172

172173
## What the SDK does not give you
173174

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from mcp.server.mcpserver import MCPServer
2+
3+
mcp = MCPServer("Unit Converter", subscriptions=False)
4+
5+
6+
@mcp.tool()
7+
def km_to_miles(km: float) -> float:
8+
"""Convert kilometres to miles."""
9+
return km * 0.621371

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def __init__(
181181
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
182182
request_state_security: RequestStateSecurity | None = None,
183183
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
184-
subscriptions: SubscriptionBus | None = None,
184+
subscriptions: SubscriptionBus | Literal[False] | None = None,
185185
middleware: Sequence[ServerMiddleware[Any]] | None = None,
186186
):
187187
self._resource_security = resource_security
@@ -204,8 +204,11 @@ def __init__(
204204
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
205205
# The subscriptions/listen fan-out seam (2026-07-28). The default bus is
206206
# in-process; pass an `SubscriptionBus` implementation over an external pub/sub
207-
# backend to fan events out across replicas.
208-
self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus()
207+
# backend to fan events out across replicas. `False` leaves `subscriptions/listen`
208+
# unserved; the bus stays so `ctx.notify_*` keeps working, with nobody listening.
209+
self._subscriptions: SubscriptionBus = (
210+
InMemorySubscriptionBus() if subscriptions is None or subscriptions is False else subscriptions
211+
)
209212
self._lowlevel_server = Server(
210213
name=name or "mcp-server",
211214
title=title,
@@ -222,7 +225,7 @@ def __init__(
222225
on_list_resource_templates=self._handle_list_resource_templates,
223226
on_list_prompts=self._handle_list_prompts,
224227
on_get_prompt=self._handle_get_prompt,
225-
on_subscriptions_listen=ListenHandler(self._subscriptions),
228+
on_subscriptions_listen=None if subscriptions is False else ListenHandler(self._subscriptions),
226229
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server.
227230
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
228231
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore

‎src/mcp/server/subscriptions.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
pub/sub for multi-replica deployments) never sees JSON-RPC. The in-process
1111
default is `InMemorySubscriptionBus`.
1212
- `ListenHandler`: the request handler that serves `subscriptions/listen`.
13-
`MCPServer` registers one automatically; lowlevel `Server` users pass an
14-
instance as `on_subscriptions_listen=`.
13+
`MCPServer` registers one unless constructed with `subscriptions=False`;
14+
lowlevel `Server` users pass an instance as `on_subscriptions_listen=`.
1515
1616
The event vocabulary lives in `mcp.shared.subscriptions`, shared with the client driver, and is re-exported here.
1717

‎tests/docs_src/test_subscriptions.py‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK."""
22

33
from collections.abc import Awaitable, Callable
4-
from typing import Any
4+
from typing import Any, Literal
55

66
import anyio
77
import mcp_types as types
88
import pytest
9+
from inline_snapshot import snapshot
10+
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
911
from trio.testing import MockClock
1012

1113
from docs_src.subscriptions import (
@@ -17,14 +19,16 @@
1719
tutorial004_trio,
1820
tutorial005,
1921
tutorial006,
22+
tutorial007,
2023
)
2124
from mcp import Client
2225
from mcp.server.auth.middleware.auth_context import auth_context_var
2326
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
2427
from mcp.server.auth.provider import AccessToken
2528
from mcp.server.context import ServerRequestContext
2629
from mcp.server.lowlevel import Server
27-
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
30+
from mcp.server.mcpserver import MCPServer
31+
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ResourceUpdated, ToolsListChanged
2832
from mcp.shared.exceptions import MCPError
2933

3034
_ReadResource = Callable[
@@ -333,3 +337,62 @@ async def test_the_middleware_refuses_a_listen_the_caller_could_not_read() -> No
333337
await client.read_resource("files://payroll.csv")
334338
finally:
335339
auth_context_var.reset(reset)
340+
341+
342+
def _replica(*, subscriptions: Literal[False] | None = None) -> MCPServer:
343+
"""One replica of the sprint board: its own server object running tutorial001's publishing tool."""
344+
mcp = MCPServer("Sprint Board", subscriptions=subscriptions)
345+
mcp.add_tool(tutorial001.complete_task)
346+
return mcp
347+
348+
349+
async def test_a_publish_on_another_replica_never_reaches_the_stream() -> None:
350+
"""The load-balancer diagram: with the default bus, a stream on replica A misses a publish on replica B.
351+
352+
The stream subscribes to both boards, so B's event would be delivered if it crossed. B publishes
353+
first, then A publishes a marker: the marker being the first event proves B's never arrived.
354+
"""
355+
async with Client(_replica()) as on_a, Client(_replica()) as on_b:
356+
async with on_a.listen(resource_subscriptions=["board://sprint", "board://backlog"]) as sub:
357+
routed_to_b = await on_b.call_tool("complete_task", {"board": "sprint", "task": "design"})
358+
await on_a.call_tool("complete_task", {"board": "backlog", "task": "tidy docs"})
359+
with anyio.fail_after(5):
360+
first_event = await anext(sub)
361+
assert first_event == ResourceUpdated(uri="board://backlog")
362+
assert routed_to_b.is_error is False # "Nothing fails": the call on B itself succeeded
363+
364+
365+
async def test_a_server_with_subscriptions_off_advertises_nothing_and_refuses_listen() -> None:
366+
"""tutorial007: a 2026-07-28 client sees every change-notification flag false, and listen is method-not-found."""
367+
async with Client(tutorial007.mcp) as client:
368+
assert client.protocol_version in MODERN_PROTOCOL_VERSIONS
369+
assert client.server_capabilities.model_dump(by_alias=True, exclude_none=True) == snapshot(
370+
{
371+
"prompts": {"listChanged": False},
372+
"resources": {"subscribe": False, "listChanged": False},
373+
"tools": {"listChanged": False},
374+
}
375+
)
376+
# Entering is where the request is sent; `__aenter__` directly avoids an unreachable with-body.
377+
with pytest.raises(MCPError) as exc_info:
378+
await client.listen(tools_list_changed=True).__aenter__()
379+
assert exc_info.value.error.code == types.METHOD_NOT_FOUND
380+
# The refusal is in-band: the connection carries on and the tool still answers.
381+
result = await client.call_tool("km_to_miles", {"km": 10})
382+
assert result.structured_content == {"result": 6.21371}
383+
384+
385+
async def test_a_publishing_handler_runs_unchanged_with_subscriptions_off() -> None:
386+
"""`ctx.notify_*` still works with `subscriptions=False`: tutorial001's publishing tool needs no change."""
387+
async with Client(_replica(subscriptions=False)) as client:
388+
result = await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
389+
assert result.is_error is False
390+
assert result.content == [types.TextContent(type="text", text="design: done")]
391+
392+
393+
async def test_subscriptions_off_changes_nothing_for_an_earlier_protocol_version() -> None:
394+
"""A client on the initialize handshake reads the same capabilities whether subscriptions are off or on."""
395+
async with Client(_replica(subscriptions=False), mode="legacy") as off, Client(_replica(), mode="legacy") as on:
396+
assert off.protocol_version == on.protocol_version
397+
assert off.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
398+
assert off.server_capabilities == on.server_capabilities

‎tests/server/mcpserver/test_server.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
INTERNAL_ERROR,
1717
INVALID_PARAMS,
1818
INVALID_REQUEST,
19+
METHOD_NOT_FOUND,
1920
MISSING_REQUIRED_CLIENT_CAPABILITY,
2021
AudioContent,
2122
BlobResourceContents,
@@ -3180,6 +3181,61 @@ async def ask(ctx: Context) -> str:
31803181
assert seen == [ToolsListChanged(), ResourcesListChanged(), PromptsListChanged()]
31813182

31823183

3184+
async def test_server_advertises_change_notifications_on_the_modern_wire_by_default() -> None:
3185+
"""SDK-defined: a default `MCPServer` serves `subscriptions/listen`, so `server/discover`
3186+
reports every change-notification flag true."""
3187+
mcp = MCPServer("board")
3188+
3189+
with anyio.fail_after(5):
3190+
async with Client(mcp) as client:
3191+
assert client.server_capabilities.model_dump(by_alias=True, exclude_none=True) == snapshot(
3192+
{
3193+
"prompts": {"listChanged": True},
3194+
"resources": {"subscribe": True, "listChanged": True},
3195+
"tools": {"listChanged": True},
3196+
}
3197+
)
3198+
3199+
3200+
async def test_subscriptions_false_neither_advertises_nor_serves_listen() -> None:
3201+
"""SDK-defined: `MCPServer(subscriptions=False)` registers no `subscriptions/listen` handler,
3202+
so `server/discover` reports every change-notification flag false and a listen request is
3203+
refused with method-not-found instead of opening a stream."""
3204+
mcp = MCPServer("static", subscriptions=False)
3205+
3206+
with anyio.fail_after(5):
3207+
async with Client(mcp) as client:
3208+
assert client.server_capabilities.model_dump(by_alias=True, exclude_none=True) == snapshot(
3209+
{
3210+
"prompts": {"listChanged": False},
3211+
"resources": {"subscribe": False, "listChanged": False},
3212+
"tools": {"listChanged": False},
3213+
}
3214+
)
3215+
# Entering is where the request is sent; `__aenter__` directly avoids an unreachable with-body.
3216+
with pytest.raises(MCPError) as exc_info:
3217+
await client.listen(tools_list_changed=True).__aenter__()
3218+
assert exc_info.value.error.code == METHOD_NOT_FOUND
3219+
3220+
3221+
async def test_notify_still_succeeds_when_subscriptions_are_off() -> None:
3222+
"""SDK-defined: with `subscriptions=False` a handler that publishes a change completes as
3223+
usual; the event has no stream to reach."""
3224+
mcp = MCPServer("static", subscriptions=False)
3225+
3226+
@mcp.tool()
3227+
async def touch(ctx: Context) -> str:
3228+
await ctx.notify_tools_changed()
3229+
return "ok"
3230+
3231+
with anyio.fail_after(5):
3232+
async with Client(mcp) as client:
3233+
result = await client.call_tool("touch")
3234+
3235+
assert result.is_error is False
3236+
assert result.content == [TextContent(type="text", text="ok")]
3237+
3238+
31833239
def test_context_mcp_server_outside_request_raises() -> None:
31843240
with pytest.raises(ValueError, match="outside of a request"):
31853241
_ = Context().mcp_server

0 commit comments

Comments
 (0)