Expose the middleware chain on MCPServer and stop sending unrequested change notifications - #3201
Conversation
📚 Documentation preview
|
There was a problem hiding this comment.
1 issue found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/migration.md">
<violation number="1" location="docs/migration.md:2826">
P2: Migrated prompt/resource notifications will create unawaited coroutines rather than publish events if readers follow the listed calls. Show `await` for each `ctx.notify_*` method.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| 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. | ||
|
|
||
| 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 gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page. |
There was a problem hiding this comment.
P2: Migrated prompt/resource notifications will create unawaited coroutines rather than publish events if readers follow the listed calls. Show await for each ctx.notify_* method.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 2826:
<comment>Migrated prompt/resource notifications will create unawaited coroutines rather than publish events if readers follow the listed calls. Show `await` for each `ctx.notify_*` method.</comment>
<file context>
@@ -2819,6 +2819,12 @@ async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_c
+
+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.
+
+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 gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page.
+
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
</file context>
| 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 gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page. | |
| Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.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 gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page. |
There was a problem hiding this comment.
Beyond the inline findings (all docs-level nits), I also checked whether the change-notification drop being enforced only on the connection-scoped NotifyOnlyOutbound leaves a bypass via request-scoped sends — it does not appear to: the request-scoped outbound is exactly how ListenHandler delivers the stamped, filtered stream frames (send_notification(..., related_request_id=subscription_id)), so the drop belongs only on the standalone channel, and a request-scoped send outside an in-flight request is already dropped.
Extended reasoning...
Bugs found this run are three docs-consistency nits (a stale narrowing reference in the client subscriptions recap, a stale low-level-only claim in the advanced index, and tutorial006's read refusal using INVALID_REQUEST instead of the SEP-2164 not-found shape), already posted inline. The one candidate code issue raised — that dropping the four change-notification methods only in NotifyOnlyOutbound leaves request-scoped sends unfiltered — was examined and ruled out: listen streams themselves deliver stamped change notifications over the request-scoped outbound (src/mcp/server/subscriptions.py), so filtering there would break the intended delivery path, and request-scoped sends are only live while their request is in flight. Not approving: the PR includes a breaking behavior change on modern stdio connections and a new authorization-relevant API surface (MCPServer middleware), which merits a human pass regardless of the nits.
| Two more properties of the handle: | ||
|
|
||
| * `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire. | ||
| * `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that supports fewer kinds acknowledges less, and an honored kind may still never fire. A server may also refuse the whole request rather than acknowledge it (see [Deciding who may watch](../handlers/subscriptions.md#deciding-who-may-watch) on the server page), which surfaces as the request's error. |
There was a problem hiding this comment.
🟡 The closing recap on this page (line 86, "Publishing these events, narrowing the filter, and scaling past one process are the server's story") still points readers to a filter-narrowing story that this PR deliberately removed from the linked server page in favor of whole-request refusal. Consider rewording it to match the new design, e.g. "Publishing these events, deciding who may watch, and scaling past one process are the server's story."
Extended reasoning...
What the bug is. The closing recap paragraph of docs/client/subscriptions.md (line 86, just outside this diff) reads: "Publishing these events, narrowing the filter, and scaling past one process are the server's story: Subscriptions." Before this PR that pointer was accurate — the server page's filter warning explicitly taught per-client narrowing ("serve the method with your own handler on the low-level Server and acknowledge a smaller filter than the client asked for"). This PR deletes that advice, so the recap now promises content the linked page no longer contains.
Why it contradicts this PR's own design. The replacement section, Deciding who may watch, takes the opposite stance: a middleware refuses the whole subscriptions/listen request with an MCPError before call_next(ctx). The PR description states this explicitly — refusing the whole request "avoids inventing narrowing semantics the spec does not yet license, and keeps the refusal from becoming a per-URI existence probe." A grep for "narrow" in the post-PR docs/handlers/subscriptions.md returns nothing; per-caller narrowing is no longer documented anywhere on that page.
Why this is an oversight introduced by this PR, not pre-existing staleness. The PR clearly intended to sweep this page: the parallel narrowing reference in the sub.honored bullet (line 23) was rewritten — from "A server that narrows the filter (see the [filter warning]...)" to "A server that supports fewer kinds acknowledges less... A server may also refuse the whole request rather than acknowledge it (see [Deciding who may watch]...)". The recap sentence at line 86 makes the same kind of reference and was simply missed.
Concrete reader walk-through. (1) A reader finishes the client subscriptions page and hits the recap: "narrowing the filter... [is] the server's story: Subscriptions." (2) They follow the link expecting guidance on how a server narrows an acknowledged filter. (3) The server page now says MCPServer "honors every kind you ask for" and its access-control section teaches whole-request refusal only — there is no narrowing content to find. (4) Worse, a reader who takes the recap at face value may implement narrowing themselves, which is exactly the semantics the PR's design note says the spec does not yet license.
Impact and fix. Docs-only; nothing breaks at runtime, but the page is internally inconsistent with the change this PR makes (line 23 describes refusal, line 86 describes narrowing) and dangles a pointer to removed content. One-sentence reword fixes it, e.g.: "Publishing these events, deciding who may watch, and scaling past one process are the server's story: Subscriptions."
Severity. All three verifiers confirmed and none refuted. Merging as-is causes no failure a user hits — this is a wording inconsistency in prose — so it does not meet the bar for blocking. Nit. (Distinct from the docs/advanced/index.md finding, which is a different stale claim in a different file.)
| `MCPServer` takes the list at construction (`MCPServer(name, middleware=[...])`) and exposes it as | ||
| `mcp.middleware`; the low-level `Server` exposes the same list as `server.middleware`. The example | ||
| below uses the low-level `Server`; if `Server(name, on_call_tool=...)` is new to you, read | ||
| **[The low-level Server](low-level-server.md)** first. |
There was a problem hiding this comment.
🟡 docs/advanced/index.md (lines 10-11, not touched by this diff) still says Pagination and Middleware are "two things you can only do on the low-level Server" — this PR makes that false for middleware, contradicting the rewritten claim on this page and the new tutorial006, which gates subscriptions/listen entirely via MCPServer(middleware=[...]). Reword the index bullet so only pagination keeps the low-level-only claim.
Extended reasoning...
What's stale. The Advanced section's index page, docs/advanced/index.md lines 10-11, reads:
Pagination and Middleware: two things you can only do on the low-level
Server.
Before this PR that sentence was accurate — docs/advanced/middleware.md itself said "This is a low-level Server feature. MCPServer does not expose a middleware list." This PR deliberately removes that limitation: MCPServer now accepts middleware=[...] at construction and exposes the live chain as mcp.middleware (src/mcp/server/mcpserver/server.py), and the diff rewrites the claim on docs/advanced/middleware.md accordingly ("MCPServer takes the list at construction ... and exposes it as mcp.middleware"). The index bullet was missed.
Why it matters. The index bullet directly contradicts the very page it links to. A reader who lands on the Advanced index — the natural entry point when deciding whether they need the low-level Server — concludes that gating subscriptions/listen per caller requires dropping down to Server(name, on_call_tool=...). That is exactly the misconception this PR set out to remove: the new docs/handlers/subscriptions.md "Deciding who may watch" section and docs_src/subscriptions/tutorial006.py do the whole gate with MCPServer("Reports", middleware=[gate_subscriptions]), never touching the low-level Server.
Concrete walk-through.
- A developer wants to restrict who may watch
files://payroll.csvand opens the docs' Advanced index. - Line 10-11 tells them middleware is only available on the low-level
Server, so they start rewriting theirMCPServerapp ontoServer(name, on_list_tools=..., on_call_tool=..., ...)with hand-written handlers. - Had they clicked through to
middleware.md(updated by this PR) orhandlers/subscriptions.md(added by this PR), they'd seeMCPServer(name, middleware=[...])does it in one keyword argument. The two pages now disagree, and the index is the wrong one.
Why nothing else catches it. The docs tests (tests/docs_src/test_subscriptions.py) exercise the tutorial code, not the prose on the index page, and the file isn't in this diff, so no reviewer of the changed hunks sees the contradiction. AGENTS.md asks that docs pages affected by a public-API change be updated in the same PR — this bullet is affected (the PR touched five other docs pages for exactly this reason) and was the one spot missed.
Fix. Reword the bullet so only pagination keeps the exclusivity claim, e.g.:
* **[Pagination](pagination.md)**: something you can *only* do on the low-level `Server`,
and **[Middleware](middleware.md)**: one async function wrapping every inbound message,
on `MCPServer` and the low-level `Server` alike.(or any phrasing that stops claiming middleware is low-level-only).
Severity. Nit: it's a one-line documentation inconsistency introduced by this PR — nothing breaks at runtime, but it steers readers toward unnecessary low-level rewrites and contradicts the page it links to, so it's worth fixing in the same PR.
| def file(name: str) -> str: | ||
| uri = f"files://{name}" | ||
| token = get_access_token() | ||
| if not can_access(token.subject if token else None, uri): | ||
| raise MCPError(INVALID_REQUEST, f"Unknown resource: {uri}") | ||
| return f"contents of {name}" |
There was a problem hiding this comment.
🟡 The read handler in tutorial006.py raises MCPError(INVALID_REQUEST, f"Unknown resource: {uri}") (-32600), but the SDK's SEP-2164 convention for a missing resource — followed by every other docs tutorial and by the SDK's own not-found path — is -32602 (INVALID_PARAMS) with {"uri": ...} in error.data. Because a non-matching URI still gets the SDK's real -32602 + uri-data error, this refusal is wire-distinguishable from a genuine not-found, which defeats the deliberate "Unknown resource" camouflage and contradicts the posture pinned by test_resource_security_rejection_indistinguishable_from_not_found. Fix: raise ResourceNotFoundError(f"Unknown resource: {uri}") instead (the middleware's INVALID_REQUEST on the listen path is fine).
Extended reasoning...
The inconsistency
docs_src/subscriptions/tutorial006.py line 37 has the files://{name} resource handler refuse an unauthorized read with:
raise MCPError(INVALID_REQUEST, f"Unknown resource: {uri}")That is JSON-RPC code -32600 with no error.data. The SDK's own convention for "resource does not exist" — standardized by SEP-2164 and stated in docs/whats-new.md ("A missing resource is -32602 with the URI in error.data") — is INVALID_PARAMS (-32602) with {"uri": ...} in error.data. MCPServer._handle_read_resource (src/mcp/server/mcpserver/server.py) implements exactly that: it catches ResourceNotFoundError and re-raises MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}).
Every other tutorial follows the convention
docs_src/handling_errors/tutorial002.py:15—raise MCPError(code=INVALID_PARAMS, ...)docs_src/handling_errors/tutorial003.py:13—raise ResourceNotFoundError(...)docs_src/troubleshooting/tutorial001.py:21—raise ResourceNotFoundError(...)
tutorial006 is the only tutorial that uses INVALID_REQUEST on the resources/read path, so the new canonical access-gating example teaches an error code the rest of the docs (and the SDK itself) contradict.
Why it undermines the tutorial's own security posture
The handler's "Unknown resource: {uri}" message is deliberate camouflage: a refusal should not confirm that a protected resource exists. But the message alone doesn't achieve that — the wire error has to match. The repo pins this exact property in tests/server/mcpserver/test_server.py::test_resource_security_rejection_indistinguishable_from_not_found, which asserts that a security rejection must produce the same code, same message shape, and same data as a genuinely absent resource.
Step-by-step proof that the camouflage is defeated as written. Take tutorial006's server with alice signed in:
- Alice reads
files://payroll.csv. Thefiles://{name}template matches,can_access("alice", "files://payroll.csv")isFalse, and the handler raisesMCPError(INVALID_REQUEST, ...). On the wire:{"code": -32600, "message": "Unknown resource: files://payroll.csv"}— nodata. - Alice reads
nosuch://thing. No template matches, so the SDK'sResourceManagerraisesResourceNotFoundError, which_handle_read_resourcemaps to:{"code": -32602, "message": "Unknown resource: nosuch://thing", "data": {"uri": "nosuch://thing"}}. - A client that compares error codes (or checks for
error.data.uri, as SEP-2164 invites) can now cleanly separate "exists but I'm not allowed" (-32600, no data) from "does not exist" (-32602 + uri data) — precisely the existence signal the "Unknown resource" phrasing was written to suppress. Additionally, an SEP-2164-aware client keying on -32602 +data.urito detect not-found will misclassify this refusal entirely.
Fix
One line: replace the raise with ResourceNotFoundError(f"Unknown resource: {uri}") (imported from mcp.server.mcpserver.exceptions, the pattern tutorial003 and troubleshooting/tutorial001 already use), or equivalently MCPError(INVALID_PARAMS, ..., data={"uri": uri}). tests/docs_src/test_subscriptions.py only asserts pytest.raises(MCPError) for the read refusal, so the swap keeps the tutorial's test green. Note this applies only to the resources/read handler — the middleware's INVALID_REQUEST on line 25 for the subscriptions/listen refusal is fine, since listen refusal has no spec-mandated code and there is no parallel "listen not-found" error to blend into.
Severity
Nit rather than blocking: this is docs example code, it runs correctly, its tests pass, and within the files:// scheme a refused URI and an unregistered name remain mutually indistinguishable — the leak only appears when comparing against URIs that match no template at all. But it is worth fixing before merge since this tutorial is positioned as the canonical per-caller authorization pattern.
… change notifications Two related changes on the 2026-07-28 subscriptions path. There was no way for a server author to authorize a `subscriptions/listen` request per caller: any client could name any resource URI in its filter and the server honored it, even for a resource its read handler would refuse. Rather than add a subscriptions-specific policy hook, expose the existing low-level middleware chain on `MCPServer` (`MCPServer(middleware=[...])` and `mcp.middleware`), so refusing a listen request per caller is an ordinary middleware that raises MCPError before the acknowledgment - the same seam that already refuses any other message. A worked example (an access table plus one `can_access` shared by the read handler and the middleware) ships as a docs tutorial with a test. Separately, the modern-era standalone channel forwarded every notification, so `ctx.session.send_tool_list_changed()` on a 2026-07-28 stdio connection wrote a bare, unstamped list_changed frame that no subscription had requested. That channel now drops the four change-notification methods (their vocabulary is single-sourced from `mcp.shared.subscriptions`); at this era they reach clients only through listen streams, which stamp and filter them.
869efd1 to
b920a39
Compare
There was a problem hiding this comment.
Beyond the inline doc-consistency findings, two behavioral concerns were examined and ruled out this run: (1) tutorial006's middleware raising on malformed subscriptions/listen params — it fails closed (the request is refused with an error, nothing is granted), matching the throwing-middleware behavior the PR itself tests; (2) send_notification(..., related_request_id=...) bypassing the new change-notification drop — the drop targets the connection-scoped standalone channel (NotifyOnlyOutbound), while related_request_id selects the separate request-scoped outbound that only lives while its request is in flight, so it is not the unrequested-standalone-frame path this PR closes.
Extended reasoning...
This run's finding is docs-only (a missed sweep of four stale sentences), posted inline. Recording the two refuted candidates so a later review pass doesn't re-explore them: the malformed-params path in tutorial006's middleware fails closed (verified against the PR's own tested throwing-middleware behavior), and the related_request_id notification path routes through the request-scoped outbound, not the NotifyOnlyOutbound channel where this PR's drop applies (verified in src/mcp/server/session.py channel selection). This is informational only, not a correctness guarantee; the PR itself — a breaking notification-behavior change plus a new public middleware surface used for authorization — still warrants human review, which the inline comments already signal.
| * A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or | ||
| appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`. |
There was a problem hiding this comment.
🟡 Four sentences missed in this PR's docs sweep now contradict the middleware surface the PR itself exposes: (1) this page's closing Recap bullet still says "Observe with it; don't build on it", contradicting the rewritten top warning (observe and refuse) and the new subscriptions/listen gating pitch; (2) docs/migration.md:1603 likewise still says "use middleware to observe (log, time, trace) rather than as a foundation"; (3) the "one middleware that ships on by default" section says "The SDK ships exactly one middleware", but mcp.middleware carries two SDK built-ins on MCPServer (OpenTelemetry plus RequestStateBoundary); (4) docs/run/opentelemetry.md:86-88 still edits the chain via the private mcp._lowlevel_server.middleware reach-through that the new public mcp.middleware supersedes. One small doc sweep fixes all four.
Extended reasoning...
What's stale. This PR rescopes docs/advanced/middleware.md to cover MCPServer's newly public mcp.middleware and deliberately upgrades the sanctioned uses from observe-only to observe and refuse — the top warning is rewritten to "Use it to observe (timing, logging, tracing) and to refuse messages", and the page (plus the new docs/handlers/subscriptions.md "Deciding who may watch" section and tutorial006.py) now pitches middleware as the way a server gates subscriptions/listen per caller. Four sentences were missed in that sweep and now contradict the PR's own changes:
1. The closing Recap bullet on this page (docs/advanced/middleware.md:114) still reads: "The whole surface is provisional. Observe with it; don't build on it." The PR edited this very Recap section (its first bullet was rewritten to mention MCPServer(middleware=[...])), so this is a missed line in the PR's own sweep, not pre-existing staleness. After this PR, the page's top warning says observe-and-refuse while its closing takeaway says observe-only — and "don't build on it" directly contradicts the page's new claim that middleware is how a server gates subscriptions/listen per caller, which is exactly building an authorization gate on it.
2. docs/migration.md:1603 still reads: "...marked provisional in the source — their signature and semantics may change — so use middleware to observe (log, time, trace) rather than as a foundation." The same guide's new section, "Change notifications travel only on subscriptions/listen streams" (added by this PR, ~line 2863), directs the same reader to "refuse subscriptions/listen in a middleware (MCPServer(middleware=[...]))" for per-caller access control. A v1 migrator reading top-to-bottom is told middleware is observation-only, then later told to hang access control on it. This matters because middleware is the only seam this PR ships for subscription authorization (#3197's dedicated hook was rejected in its favor) — a reader trusting the observe-only guidance concludes the SDK offers no supported gate.
3. "The one middleware that ships on by default" (docs/advanced/middleware.md:98-99): "The SDK ships exactly one middleware, and it is already on your server's list." That was accurate when the page was scoped to the low-level Server ("MCPServer does not expose a middleware list"), whose __init__ seeds middleware = [OpenTelemetryMiddleware()] (src/mcp/server/lowlevel/server.py:439). But this PR rewrites the page to document mcp.middleware — and on that list MCPServer.__init__ unconditionally appends a second SDK built-in, RequestStateBoundary (src/mcp/server/mcpserver/server.py:231), before extending with user middleware. The PR's own comment says "the SDK's built-ins (OpenTelemetry, then the request-state boundary)" — plural — and its test asserts mcp.middleware[-1] sits "after the built-ins".
Step-by-step proof for (3): (a) mcp = MCPServer(\"x\", middleware=[my_mw]); (b) low-level Server.__init__ seeds [OpenTelemetryMiddleware()]; (c) MCPServer.__init__ appends RequestStateBoundary(...), then extends with [my_mw]; (d) print(mcp.middleware) → three entries, two of them SDK-shipped — while the docs promise "exactly one". A user who "knows" only OTel ships and rebuilds the list (as the docs teach with in-place list surgery) can silently drop RequestStateBoundary, which seals/verifies requestState for the 2026 multi-round-trip flow — breaking Resolve/InputRequiredResult handling with no warning. (The documented isinstance-based filter would retain it, so the harm is hypothetical, but the factual claim is now false for exactly the surface the PR exposes.)
4. docs/run/opentelemetry.md:86-88 ("Turning it off") still edits the chain via the private attribute: mcp._lowlevel_server.middleware[:] = [m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)]. That is the same live list this PR publishes as mcp.middleware — the private reach-through this snippet works around is exactly what the PR eliminated. Per AGENTS.md, docs pages affected by a public-API change should be updated in the same PR; five other docs pages were swept for this feature and this one was missed.
Fix. One doc sweep: reword the middleware.md recap bullet (e.g. "Observe and refuse with it; don't build your server's core on it."), align migration.md:1603 ("...to observe (log, time, trace) and to refuse messages rather than as a foundation"), reword the "exactly one" section (the low-level Server ships one middleware, OpenTelemetry; MCPServer adds a second built-in, the request-state boundary — leave it in place), and switch opentelemetry.md's snippet to mcp.middleware.
Severity. Docs-only; nothing breaks at runtime, so nit. Distinct from the already-posted comments (the docs/client/subscriptions.md:86 filter-narrowing recap, the docs/advanced/index.md low-level-only availability claim, and the tutorial006 error-code finding) — these are different stale sentences.
Two related changes on the 2026-07-28
subscriptions/listenserver path. Supersedes #3197 (a subscriptions-specific authorization hook), taking the smaller route of reusing the existing middleware seam instead of adding a policy callback.Motivation and Context
Per-caller authorization on subscribe. A client can name any resource URI in its
subscriptions/listenfilter and the server honors it — nothing consults the read handler, because nothing is being read — so a caller yourresources/readhandler would refuse could still subscribe to that URI and observe its change events (URIs and activity timing; change notifications carry no content). Rather than invent a subscriptions-only hook, this exposes the existing low-level middleware chain onMCPServer:MCPServer(name, middleware=[...])at construction andmcp.middleware(the same live list as the low-levelServer.middleware). Refusing a listen request per caller is then an ordinary middleware that raisesMCPErrorbefore callingcall_next(ctx)— in-band error, no acknowledgment, no stream, connection intact. A tested docs tutorial (docs_src/subscriptions/tutorial006.py) shows the pattern: an explicit access table, onecan_access(user, uri)shared by the read handler and the middleware, so read and watch authorization stay in step and swap together for a database or RBAC lookup. The middleware seam stays marked provisional; the docs now name observe and refuse as its intended uses.Unrequested change notifications on modern connections. The modern-era standalone channel (
NotifyOnlyOutbound) forwarded every notification, soctx.session.send_tool_list_changed()on a 2026-07-28 stdio connection wrote a bare, unstampednotifications/tools/list_changedthat no subscription had requested — bypassing subscribe-time filtering entirely. That channel now drops the four change-notification methods with a debug log (the method set is single-sourced frommcp.shared.subscriptions); at this era they reach clients only through listen streams, which stamp and filter them. Publish viactx.notify_*/ theSubscriptionBus.How Has This Been Tested?
Unit tests for the
middlewarekwarg/property wiring, a middleware refusingsubscriptions/listenpre-ack over an in-memory client, the dropped notification methods, and the docs tutorial end to end (accepted read/listen, refused listen, refused read). Also driven over real stdio with raw JSON-RPC frames: acknowledged listen, only stamped stream frames after a publish (no barelist_changed), in-band refusal with no ack, and a middleware that itself throws surfacing as an internal error without granting.Breaking Changes
ctx.session.send_tool_list_changed()/send_prompt_list_changed()/send_resource_list_changed()/send_resource_updated()are now dropped (debug-logged) on 2026-07-28 stdio connections instead of being written as bare frames; pre-2026 connections are unaffected, and modern HTTP already had no standalone channel to write to. Migrate to thenotify_*bus methods — documented indocs/migration.md.MCPServer(middleware=...)is additive.Types of changes
Checklist
Additional context
The spec is silent on filter authorization, and no other SDK offers a per-caller seam on the listen path today; refusing the whole request in middleware (as opposed to narrowing the acknowledged filter) avoids inventing narrowing semantics the spec does not yet license, and keeps the refusal from becoming a per-URI existence probe.
AI Disclaimer