Skip to content

Commit 6c11684

Browse files
committed
Make the subscription authorization hook accept-or-refuse instead of narrowing
Rework the `subscriptions/listen` authorization seam to Go's shape: the hook (now `authorize=` on ListenHandler / `authorize_subscriptions=` on MCPServer, typed `AuthorizeSubscription`) either returns to accept the request as asked or raises MCPError to refuse the whole request before the acknowledgment. It no longer returns a narrowed filter, and the honored subset is again the plain support-based echo of the request rather than an intersection with a grant. Refusing on any unauthorized URI, with a uniform error, is now the documented pattern; a non-MCPError from the hook still fails closed. No-Verification-Needed: pushing early for review at maintainer request; verification to follow
1 parent b07b2b3 commit 6c11684

6 files changed

Lines changed: 85 additions & 146 deletions

File tree

docs/handlers/subscriptions.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,34 +43,33 @@ Two things the stream is *not*:
4343
* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch.
4444
* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only.
4545

46-
## Deciding what a caller may watch
46+
## Deciding who may watch
4747

48-
By default every requested kind and URI is honored: any caller may watch any URI you publish. On a multi-tenant server, decide per caller with a narrowing hook. It runs once per `subscriptions/listen`, before the acknowledgment, with the request context and the filter the client asked for, and returns the filter to grant:
48+
By default every requested kind and URI is honored: any caller may watch any URI you publish. On a multi-tenant server, gate that with an authorization hook. It runs once per `subscriptions/listen`, before the acknowledgment, with the request context and the filter the client asked for, and either returns (accept the request as asked) or raises `MCPError` (refuse it):
4949

5050
```python
5151
from mcp.server.auth.middleware.auth_context import get_access_token
5252
from mcp.server.context import ServerRequestContext
5353
from mcp.server.mcpserver import MCPServer
54-
from mcp_types import SubscriptionFilter
54+
from mcp.shared.exceptions import MCPError
55+
from mcp_types import INVALID_REQUEST, SubscriptionFilter
5556

5657

57-
async def owned_only(ctx: ServerRequestContext, requested: SubscriptionFilter) -> SubscriptionFilter:
58+
async def only_own_resources(ctx: ServerRequestContext, requested: SubscriptionFilter) -> None:
5859
token = get_access_token()
5960
prefix = f"user://{token.subject}/" if token and token.subject else ""
60-
watchable = [uri for uri in requested.resource_subscriptions or [] if prefix and uri.startswith(prefix)]
61-
return requested.model_copy(update={"resource_subscriptions": watchable})
61+
if any(not (prefix and uri.startswith(prefix)) for uri in requested.resource_subscriptions or []):
62+
raise MCPError(INVALID_REQUEST, "not permitted to watch the requested resources")
6263

6364

64-
mcp = MCPServer("Sprint Board", narrow_subscriptions=owned_only)
65+
mcp = MCPServer("Sprint Board", authorize_subscriptions=only_own_resources)
6566
```
6667

67-
* The grant is intersected with the request, so a hook can drop kinds and URIs but never add them.
68-
* The acknowledgment reports the grant; that is how the client learns it got less than it asked for.
69-
* Decide by pattern, not by lookup. `owned_only` prunes everything outside the caller's own prefix without checking whether a URI exists, so the acknowledgment reveals the shape of the policy and nothing about which URIs are real.
70-
* To refuse the whole subscription, raise `MCPError` from the hook: the client gets the error and no stream. Any other exception refuses too - a broken policy never grants.
71-
* The decision holds for the stream's lifetime. There is no per-event re-check, so if a caller's access can lapse mid-stream (an expiring token), end that caller's connection when it does. `ListenHandler.close()` is not the tool for that: it ends every open stream at once, which you want at shutdown, not for one caller.
68+
* The hook decides on the whole request: accept it as asked, or refuse it. It does not narrow the filter - the acknowledgment reflects what the server supports, not an authorization verdict.
69+
* A refused request gets the error and no stream. Keep the error uniform (name no URI) so refusals do not reveal which URIs are protected. Any other exception refuses too: a broken policy never grants.
70+
* The verdict holds for the stream's lifetime. There is no per-event re-check, so if a caller's access can lapse mid-stream (an expiring token), end that caller's connection when it does. `ListenHandler.close()` is not the tool for that: it ends every open stream at once, which you want at shutdown, not for one caller.
7271

73-
On the low-level `Server` the same hook is `ListenHandler(bus, narrow=owned_only)`.
72+
On the low-level `Server` the same hook is `ListenHandler(bus, authorize=only_own_resources)`.
7473

7574
Without a hook the exposure is narrow but real, so weigh it before publishing per-user URIs from a multi-tenant server: a subscriber learns that a URI it can guess changed, and when. It never learns content, and it cannot probe what exists, because an unknown URI is honored too and simply never fires.
7675

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ This parameter was redundant because the SSE transport already handles sub-path
721721

722722
### Transport-specific parameters moved from MCPServer constructor to run()/app methods
723723

724-
Transport-specific parameters have been moved off the `MCPServer` constructor and onto `run()`, `sse_app()`, and `streamable_http_app()`, so transport configuration is passed when starting or building the server. The rest of the constructor is unchanged: identity (`name`, `instructions`, `website_url`, `icons`, plus the newly added positional `title`, `description`, and `version` covered [above](#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters)), authentication (`auth`, `token_verifier`, `auth_server_provider`), `lifespan`, `dependencies`, `tools`, `debug`, `log_level`, and the `warn_on_duplicate_*` flags; the new keyword-only parameters (`resources`, `extensions`, `resource_security`, `request_state_security`, `cache_hints`, `subscriptions`, `narrow_subscriptions`) are additive.
724+
Transport-specific parameters have been moved off the `MCPServer` constructor and onto `run()`, `sse_app()`, and `streamable_http_app()`, so transport configuration is passed when starting or building the server. The rest of the constructor is unchanged: identity (`name`, `instructions`, `website_url`, `icons`, plus the newly added positional `title`, `description`, and `version` covered [above](#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters)), authentication (`auth`, `token_verifier`, `auth_server_provider`), `lifespan`, `dependencies`, `tools`, `debug`, `log_level`, and the `warn_on_duplicate_*` flags; the new keyword-only parameters (`resources`, `extensions`, `resource_security`, `request_state_security`, `cache_hints`, `subscriptions`, `authorize_subscriptions`) are additive.
725725

726726
**Parameters moved:**
727727

@@ -2823,7 +2823,7 @@ The client's same `elicitation_callback` answers both; the resolver lets the ser
28232823

28242824
On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/prompts/list_changed`, `notifications/resources/list_changed`, and `notifications/resources/updated` reach a client only through a `subscriptions/listen` stream it opened — the spec forbids sending a notification type a subscription did not request. The v1-style session helpers (`ctx.session.send_tool_list_changed()`, `send_prompt_list_changed()`, `send_resource_list_changed()`, `send_resource_updated(uri)`) push a bare copy onto the connection's standalone channel instead, so on such a connection they are dropped with a debug log: silently on streamable HTTP (there is no standalone channel), and on stdio, where earlier v2 releases wrote the bare notification to the shared pipe, it is now dropped too. On pre-2026 connections the helpers behave as in v1.
28252825

2826-
Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`, and `notify_resource_updated(uri)` on `MCPServer`'s `Context`, or `await bus.publish(...)` on a low-level `Server`'s own `SubscriptionBus` — see [Subscriptions](handlers/subscriptions.md). A stream only ever receives the kinds and URIs the server acknowledged for it; to decide per caller what that is, pass a `narrow_subscriptions` hook (`MCPServer(narrow_subscriptions=...)`, or `ListenHandler(bus, narrow=...)` on the low-level `Server`), covered on the same page.
2826+
Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`, and `notify_resource_updated(uri)` on `MCPServer`'s `Context`, or `await bus.publish(...)` on a low-level `Server`'s own `SubscriptionBus` — see [Subscriptions](handlers/subscriptions.md). A stream only ever receives the kinds and URIs the server acknowledged for it; to accept or refuse a listen request per caller, pass an `authorize_subscriptions` hook (`MCPServer(authorize_subscriptions=...)`, or `ListenHandler(bus, authorize=...)` on the low-level `Server`), covered on the same page.
28272827

28282828
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
28292829

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and
191191

192192
### Change notifications become one stream
193193

194-
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), a `narrow_subscriptions` hook decides per caller what a stream may watch, and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver.
194+
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), an `authorize_subscriptions` hook accepts or refuses each listen request per caller, and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver.
195195

196196
**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus.
197197

src/mcp/server/mcpserver/server.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
from mcp.server.stdio import stdio_server
8989
from mcp.server.streamable_http import EventStore
9090
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
91-
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, NarrowSubscription, SubscriptionBus
91+
from mcp.server.subscriptions import AuthorizeSubscription, InMemorySubscriptionBus, ListenHandler, SubscriptionBus
9292
from mcp.server.transport_security import TransportSecuritySettings
9393
from mcp.shared.exceptions import MCPError
9494
from mcp.shared.uri_template import UriTemplate
@@ -172,7 +172,7 @@ def __init__(
172172
request_state_security: RequestStateSecurity | None = None,
173173
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
174174
subscriptions: SubscriptionBus | None = None,
175-
narrow_subscriptions: NarrowSubscription | None = None,
175+
authorize_subscriptions: AuthorizeSubscription | None = None,
176176
):
177177
self._resource_security = resource_security
178178
self.settings = Settings(
@@ -194,9 +194,9 @@ def __init__(
194194
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
195195
# The subscriptions/listen fan-out seam (2026-07-28). The default bus is
196196
# in-process; pass an `SubscriptionBus` implementation over an external pub/sub
197-
# backend to fan events out across replicas. `narrow_subscriptions` is the
198-
# per-request grant hook (see `NarrowSubscription`); without it every
199-
# requested kind and resource URI is honored.
197+
# backend to fan events out across replicas. `authorize_subscriptions` is
198+
# the per-request accept-or-refuse hook (see `AuthorizeSubscription`);
199+
# without it every requested kind and resource URI is honored.
200200
self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus()
201201
self._lowlevel_server = Server(
202202
name=name or "mcp-server",
@@ -214,7 +214,7 @@ def __init__(
214214
on_list_resource_templates=self._handle_list_resource_templates,
215215
on_list_prompts=self._handle_list_prompts,
216216
on_get_prompt=self._handle_get_prompt,
217-
on_subscriptions_listen=ListenHandler(self._subscriptions, narrow=narrow_subscriptions),
217+
on_subscriptions_listen=ListenHandler(self._subscriptions, authorize=authorize_subscriptions),
218218
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server.
219219
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
220220
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore

0 commit comments

Comments
 (0)