Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
You write it as `async (ctx, call_next)` and append it to `server.middleware`. That is the whole API.

!!! warning
`Server.middleware` is marked **provisional** in the source. The signature and semantics are
expected to change before v2 is final. Use it to *observe*: timing, logging, tracing.
Do not make it the foundation your server stands on.
The middleware list is marked **provisional** in the source. The signature and semantics are
expected to change before v2 is final. Use it to *observe* (timing, logging, tracing) and to
*refuse* messages; do not make it the foundation your server stands on.

This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list.
If `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first.
`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.
Comment on lines +12 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

  1. A developer wants to restrict who may watch files://payroll.csv and opens the docs' Advanced index.
  2. Line 10-11 tells them middleware is only available on the low-level Server, so they start rewriting their MCPServer app onto Server(name, on_list_tools=..., on_call_tool=..., ...) with hand-written handlers.
  3. Had they clicked through to middleware.md (updated by this PR) or handlers/subscriptions.md (added by this PR), they'd see MCPServer(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.


## A timing middleware

Expand Down Expand Up @@ -57,7 +59,10 @@

* **Observe.** Time it, count it, log it. The example above.
* **Refuse.** Raise an `MCPError` *instead of* calling `call_next(ctx)` and that one message is
answered with a JSON-RPC error. The connection stays up; the next message goes through.
answered with a JSON-RPC error. The connection stays up; the next message goes through. This is
how a server gates `subscriptions/listen` per caller:
**[Deciding who may watch](../handlers/subscriptions.md#deciding-who-may-watch)** on the
Subscriptions page walks through it.
* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))`
hands the rest of the chain different params than the client sent. Never do this to
`initialize`: the result the client gets back is built from your rewritten params, but the
Expand Down Expand Up @@ -98,8 +103,8 @@

## Recap

* A middleware is `async (ctx, call_next) -> result`, appended to `server.middleware` on the
low-level `Server`.
* 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`.

Check warning on line 107 in docs/advanced/middleware.md

View check run for this annotation

Claude / Claude Code Review

Middleware docs sweep missed four stale sentences that now contradict the exposed mcp.middleware surface

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 sh
Comment on lines +106 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
unknown methods) and runs outermost-first.
* `ctx.request_id is None` is how you tell a notification from a request.
Expand Down
2 changes: 1 addition & 1 deletion docs/client/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Duplicate events waiting to be consumed collapse into one, and refetching still

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.)

* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id.

## Watching without blocking
Expand Down
24 changes: 16 additions & 8 deletions docs/handlers/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,22 @@ Two things the stream is *not*:
* **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.
* **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.

!!! warning
Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant
server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure
is narrow but real: 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. To narrow the filter per client today, serve the method with your own
handler on the low-level `Server` and acknowledge a smaller filter than the client asked
for; the acknowledgment is how the client learns what it actually got.
## Deciding who may watch

By default every requested kind and URI is honored: any caller may watch any URI you publish. Nothing consults your read handler, because nobody is reading — a caller your `files://{name}` handler would turn away can still open a stream on `files://payroll.csv` and learn that it changed, and when. It never learns content, and it cannot probe what exists, because an unknown URI is honored too and simply never fires. Narrow but real, so gate it before you publish per-user URIs from a multi-tenant server.

The gate is a middleware. It sees the `subscriptions/listen` request before the SDK acknowledges it and refuses when the caller asks for anything they may not read:

```python title="server.py" hl_lines="19-26 29"
--8<-- "docs_src/subscriptions/tutorial006.py"
```

* `ctx.params` is the raw request, so the middleware validates it into `SubscriptionsListenRequestParams` itself and reads the filter the client asked for.
* Refusal is a raised `MCPError` before `call_next(ctx)`: the client gets that error and no stream, and the connection carries on. Keep the message uniform, naming no URI, so a refusal never confirms which URIs are protected.
* One `can_access(user, uri)` answers both questions. The resource handler asks it on `resources/read`; the middleware asks it on `subscriptions/listen`. Swap the table for a database or your RBAC system and both stay in step.
* 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.

The full middleware contract, including what else it wraps and why it is marked provisional, is on **[Middleware](../advanced/middleware.md)**.

## The client end

Expand Down
12 changes: 9 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ This parameter was redundant because the SSE transport already handles sub-path

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

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`) are additive.
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`, `middleware`) are additive.

**Parameters moved:**

Expand Down Expand Up @@ -1718,7 +1718,7 @@ The helpers keep their v1 signatures, so calls through `ctx.session` are source-
Behavior changes:

- **A new `ServerSession` proxy is built for every inbound message.** In v1 one `ServerSession` lived for the whole connection, and servers commonly keyed per-client state on `ctx.session` identity (a `WeakKeyDictionary[ServerSession, ...]`, `id(ctx.session)`, a set of captured sessions to notify later). In v2 each request and notification gets a fresh proxy over the same connection, so those idioms silently misbehave: a session-keyed dict never finds an earlier key, and a broadcast set grows by one entry per request, sending duplicates. Key on something connection-stable instead — on stateful streamable HTTP the `mcp-session-id` request header names the transport session (read it via `ctx.headers` on `MCPServer` or `ctx.request.headers` in a lowlevel handler); on stdio there is one connection per process. The per-connection object the proxies share is `mcp.server.connection.Connection` (`state`, `session_id`, `exit_stack`), which is not currently reachable from `ctx`.
- **A captured `ctx.session` stays usable after the handler returns.** The proxy holds the connection, not the request, so a background task can keep calling `send_resource_updated()` / `send_tool_list_changed()` on it while the client stays connected; with `related_request_id` omitted these ride the standalone stream as in v1. A request-scoped send is only meaningful while that request is in flight — once the handler returns, that stream is closed and the message is dropped with a debug log.
- **A captured `ctx.session` stays usable after the handler returns.** The proxy holds the connection, not the request, so a background task can keep calling `send_resource_updated()` / `send_tool_list_changed()` on it while the client stays connected; with `related_request_id` omitted these ride the standalone stream as in v1 — except on a 2026-era connection, where change notifications are dropped and belong on the subscription bus instead ([change notifications travel only on `subscriptions/listen` streams](#change-notifications-travel-only-on-subscriptionslisten-streams)). A request-scoped send is only meaningful while that request is in flight — once the handler returns, that stream is closed and the message is dropped with a debug log.
- **Notifications after the connection has closed are dropped instead of raising.** In v1 the notification helpers raised `anyio.ClosedResourceError`/`anyio.BrokenResourceError` on a dead connection, and broadcast loops used that exception to prune sessions. In v2 the send returns normally (the drop is debug-logged), so probe with a request instead: `await session.send_ping()` raises `MCPError` once the connection has closed. On a 2026-07-28 connection, though, every server-initiated request raises `NoBackChannelError` (an `MCPError`) regardless, so a ping is a liveness probe only on connections negotiated at 2025-11-25 or earlier.

`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so beyond the behavior changes above, most servers are unaffected. If you were constructing or subclassing it directly:
Expand Down Expand Up @@ -2804,7 +2804,7 @@ rest of this guide stays focused on the v1-to-v2 upgrade itself.

The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client.

`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).
`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists (and the change-notification helpers are dropped on every 2026-era connection, channel or not — see [change notifications travel only on `subscriptions/listen` streams](#change-notifications-travel-only-on-subscriptionslisten-streams)), and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).

Two ways to migrate:

Expand Down Expand Up @@ -2856,6 +2856,12 @@ async with Client(server, logging_callback=on_log, log_level="info") as client:

`log_level=None` (the default) means no opt-in — a `logging_callback` alone is not one — and a single request can override the client-wide default by supplying the key in its own `meta=` (e.g. `meta={LOG_LEVEL_META_KEY: "debug"}` from `mcp_types`). The opt-in is what the spec calls for on 2026-era servers generally, not just this SDK's. Because 2026 log delivery is request-scoped by construction, `related_request_id` on `send_log_message` no longer selects the standalone stream there: whatever is delivered rides the requesting stream.

### Change notifications travel only on `subscriptions/listen` streams

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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.


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

On the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. On the client side, `ClientSession.call_tool` emits these headers automatically for annotated arguments of any tool it has listed; list the tool first, and note that pre-2026 connections and non-HTTP transports never emit them.
Expand Down
Loading
Loading