Skip to content

Commit 855019d

Browse files
committed
Let MCPServer opt out of serving subscriptions/listen
`MCPServer` always registered a `subscriptions/listen` handler, so on the 2026-07-28 wire every server advertised `listChanged` and `resources.subscribe` as true and held a listen stream open for any client that asked, even when it never publishes a change. There was no supported way to turn that off. The existing `subscriptions` keyword now also accepts `False`. The server then registers no listen handler: `server/discover` reports the change-notification flags as false and a `subscriptions/listen` request is answered with method-not-found. `ctx.notify_*` stays callable and reaches nobody. `None` and a bus keep their meaning, so the default is unchanged. Fixes #3357
1 parent 0809579 commit 855019d

4 files changed

Lines changed: 67 additions & 6 deletions

File tree

‎docs/handlers/subscriptions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Your side of it is one line: publish the change.
1919

2020
`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.
2121

22+
A server that never publishes can opt out with `MCPServer("Static", subscriptions=False)`: it advertises no change notifications, and a `subscriptions/listen` request gets a *Method not found* error instead of an open stream.
23+
2224
!!! check
2325
On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs:
2426

‎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/server/mcpserver/test_server.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
INTERNAL_ERROR,
1414
INVALID_PARAMS,
1515
INVALID_REQUEST,
16+
METHOD_NOT_FOUND,
1617
MISSING_REQUIRED_CLIENT_CAPABILITY,
1718
AudioContent,
1819
BlobResourceContents,
@@ -3109,6 +3110,61 @@ async def ask(ctx: Context) -> str:
31093110
assert seen == [ToolsListChanged(), ResourcesListChanged(), PromptsListChanged()]
31103111

31113112

3113+
async def test_server_advertises_change_notifications_on_the_modern_wire_by_default() -> None:
3114+
"""SDK-defined: a default `MCPServer` serves `subscriptions/listen`, so `server/discover`
3115+
reports every change-notification flag true."""
3116+
mcp = MCPServer("board")
3117+
3118+
with anyio.fail_after(5):
3119+
async with Client(mcp) as client:
3120+
assert client.server_capabilities.model_dump(by_alias=True, exclude_none=True) == snapshot(
3121+
{
3122+
"prompts": {"listChanged": True},
3123+
"resources": {"subscribe": True, "listChanged": True},
3124+
"tools": {"listChanged": True},
3125+
}
3126+
)
3127+
3128+
3129+
async def test_subscriptions_false_neither_advertises_nor_serves_listen() -> None:
3130+
"""SDK-defined: `MCPServer(subscriptions=False)` registers no `subscriptions/listen` handler,
3131+
so `server/discover` reports every change-notification flag false and a listen request is
3132+
refused with method-not-found instead of opening a stream."""
3133+
mcp = MCPServer("static", subscriptions=False)
3134+
3135+
with anyio.fail_after(5):
3136+
async with Client(mcp) as client:
3137+
assert client.server_capabilities.model_dump(by_alias=True, exclude_none=True) == snapshot(
3138+
{
3139+
"prompts": {"listChanged": False},
3140+
"resources": {"subscribe": False, "listChanged": False},
3141+
"tools": {"listChanged": False},
3142+
}
3143+
)
3144+
# Entering is where the request is sent; `__aenter__` directly avoids an unreachable with-body.
3145+
with pytest.raises(MCPError) as exc_info:
3146+
await client.listen(tools_list_changed=True).__aenter__()
3147+
assert exc_info.value.error.code == METHOD_NOT_FOUND
3148+
3149+
3150+
async def test_notify_still_succeeds_when_subscriptions_are_off() -> None:
3151+
"""SDK-defined: with `subscriptions=False` a handler that publishes a change completes as
3152+
usual; the event has no stream to reach."""
3153+
mcp = MCPServer("static", subscriptions=False)
3154+
3155+
@mcp.tool()
3156+
async def touch(ctx: Context) -> str:
3157+
await ctx.notify_tools_changed()
3158+
return "ok"
3159+
3160+
with anyio.fail_after(5):
3161+
async with Client(mcp) as client:
3162+
result = await client.call_tool("touch")
3163+
3164+
assert result.is_error is False
3165+
assert result.content == [TextContent(type="text", text="ok")]
3166+
3167+
31123168
def test_context_mcp_server_outside_request_raises() -> None:
31133169
with pytest.raises(ValueError, match="outside of a request"):
31143170
_ = Context().mcp_server

0 commit comments

Comments
 (0)