Gate log notifications on the per-request log-level opt-in at 2026-07-28 - #3198
Conversation
The 2026-07-28 spec makes log delivery a per-request opt-in: a server must not send notifications/message for a request whose _meta lacks io.modelcontextprotocol/logLevel, may send only at or above that level when it is present, and never on any stream but the one carrying the response. The SDK sent unconditionally on modern connections. allowed_log_levels(protocol_version, meta) in server/connection.py is the one gate: handshake versions may send every level as before, modern versions the at-or-above subset of the request's opt-in, or nothing without one. ServerSession fixes the set at construction from the inbound request's _meta and routes modern logs onto the requesting stream regardless of related_request_id; Context.log applies the same gate; Connection.log, with no request to opt in, never sends at 2026. Client(log_level=...) stamps the opt-in on every modern request (a per-call _meta entry overrides it), so a logging_callback on a 2026 connection can receive messages.
📚 Documentation preview
|
The streaming story's client asserts that three log messages arrive during the countdown, but never opted in, so on 2026-07-28 arms it depended on the server hand-sending the notification around the per-request log gate. Pass log_level="info" so the story models the spec-correct opt-in, and rewrite the server comment that promised a non-deprecated logging helper - protocol logging is deprecated (SEP-2577), so the raw send is what keeps the example warning-free.
There was a problem hiding this comment.
Review completed against the latest diff
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| never crosses the `Outbound` Protocol. | ||
| """ | ||
|
|
||
| def __init__(self, request_outbound: DispatchContext[Any], connection: Connection) -> None: | ||
| def __init__( |
There was a problem hiding this comment.
🟡 The ServerSession class docstring still states the unqualified routing rule — "related_request_id on the public methods is the selector — present means request-scoped, absent means standalone" — but this PR deliberately breaks that rule for send_log_message on 2026-07-28+ connections, where delivery is request-scoped even without a related_request_id. Add the same one-line send_log_message caveat here that the method docstring and docs/migration.md already carry.
Extended reasoning...
What the bug is. The class-level docstring of ServerSession (src/mcp/server/session.py:44-47) documents the channel-selection contract for all public methods: "related_request_id on the public methods is the selector — present means request-scoped, absent means standalone." This PR intentionally makes that statement false for one method: on 2026-07-28+ connections, send_log_message routes onto the request-scoped channel regardless of related_request_id, because the spec forbids notifications/message on any stream but the one carrying the request's response.
The code path. In __init__ (modified by this PR), self._log_is_request_scoped = connection.protocol_version in MODERN_PROTOCOL_VERSIONS is fixed at construction. send_log_message then calls self._notify(..., request_scoped=self._log_is_request_scoped or related_request_id is not None) — so on a modern connection, an absent related_request_id still selects the request channel, contradicting the class docstring's "absent means standalone."
Step-by-step proof (this is exactly what the PR's own test test_send_log_message_is_request_scoped_at_2026_even_without_related_request_id in tests/server/test_session.py pins):
- Build a 2026-era session:
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=standalone_ch);session = ServerSession(request_ch, conn, request_meta={LOG_LEVEL_META_KEY: "debug"}). - Call
await session.send_log_message("info", "hello")— norelated_request_id. - Per the class docstring, absent
related_request_idshould mean the standalone channel (connection.outbound). - Actually:
_log_is_request_scopedisTrue, sorequest_scoped=Trueand the notification lands onrequest_ch; the test assertsrequest_ch.notifications == ["notifications/message"]andstandalone_ch.notifications == [].
Why nothing else catches it. The PR did document the exception — twice: the send_log_message method docstring ("What is sent rides this request's stream regardless of related_request_id") and docs/migration.md ("the one 2026-era exception being send_log_message"). But the class docstring is the natural first place a reader learns the routing contract, and it was left stating the blanket rule in the very class this PR modified. A reader who stops at the class doc is told absent == standalone unconditionally.
Impact. Documentation-only: no runtime behavior is wrong, the behavior itself is spec-mandated and well tested. The cost is a misleading contract statement that could lead a developer to expect standalone-stream delivery for log messages on modern connections.
How to fix. Mirror the caveat already used in docs/migration.md into the class docstring, e.g. append: "...never crosses the Outbound Protocol — with one 2026-era exception: send_log_message delivery is gated and request-scoped by the spec there, regardless of related_request_id."
| ### Log messages are delivered only to requests that opt in | ||
|
|
||
| At 2026-07-28 the deprecated logging capability changes shape: `logging/setLevel` is gone, and log delivery becomes a per-request opt-in. A server MUST NOT send `notifications/message` for a request whose `_meta` lacks `io.modelcontextprotocol/logLevel`, and when the key is present it sends only entries at or above that level, on that request's own stream. So on a 2026-era connection the request-scoped log calls — `ctx.info(...)` and friends on `MCPServer`'s `Context`, `ctx.session.send_log_message(...)`, `Context.log(...)` — are silently dropped (debug-logged) unless the request opted in, and dropped when they fall below the requested level; `Connection.log(...)`, which has no request to opt in, never sends there. Nothing changes on 2025-11-25 and earlier connections. | ||
|
|
||
| The most visible consequence is the in-process `Client(server)`, which negotiates 2026-07-28 by default: a `logging_callback` that used to receive every message now receives nothing until the client opts in. `Client` grows a `log_level` argument for exactly this, stamped as the reserved `_meta` key on every modern request: | ||
|
|
||
| ```python | ||
| async with Client(server, logging_callback=on_log, log_level="info") as client: | ||
| await client.call_tool("chatty", {}) # info and above reach `on_log` | ||
| ``` | ||
|
|
||
| `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. | ||
|
|
There was a problem hiding this comment.
🟡 This new migration section is now contradicted by docs/deprecated.md and docs/whats-new.md, which the PR leaves untouched: deprecated.md's warning box and Recap still name only sampling and roots as the SEP-2577 features that actually stop working on a 2026-07-28 connection ("Nothing breaks today" / "There are no wire changes"), and whats-new.md still says "nothing changes on the wire" — but after this PR, protocol logging on a modern connection (including the default in-process Client(server)) delivers nothing unless the request opts in. A sentence in deprecated.md's warning box (plus a clause in its Recap and in whats-new.md) linking to this section would restore consistency with the updated callbacks.md.
Extended reasoning...
The inconsistency. This PR gates 2026-07-28 log delivery on the per-request io.modelcontextprotocol/logLevel opt-in and documents that as a breaking change here in docs/migration.md and in docs/client/callbacks.md ("On a 2026-era connection the callback alone gets you nothing"). But docs/deprecated.md — the page other docs defer to as the authoritative account of how the SEP-2577-deprecated features behave (docs/handlers/logging.md points readers there) — is not in the diff and still asserts the pre-PR behavior:
- Lines 25–27 ("Deprecated is advisory"): "Nothing breaks today." / "There are no wire changes and capability negotiation is unchanged."
- Lines 37–52 (the warning box): "'Advisory' stops at the wire. Sampling and roots are server-to-client requests, and a 2026-07-28 session has no channel to carry one." — it enumerates the deprecated features that actually stop working on a modern connection and names only sampling and roots.
- Lines 86–87 (Recap): "no wire changes, everything keeps working" and "Sampling and roots additionally need a back-channel … On a modern connection they warn and then they raise" — logging is again implied to be the exception-free case.
docs/whats-new.md likewise says "deprecated is advisory, everything keeps working against 2025-era sessions, and nothing changes on the wire. What you notice is MCPDeprecationWarning"; the following paragraph mentions the logging/setLevel removal but not the opt-in gate.
Why it's now wrong. After this PR, on a 2026 connection every protocol-log emitter — ctx.info() and friends on MCPServer's Context, ctx.session.send_log_message(...), Context.log — is silently dropped (debug-logged) unless the request's _meta carries the reserved key, and Connection.log never sends there at all (allowed_log_levels returns frozenset() with meta=None). So "nothing changes on the wire" and "only sampling and roots stop working on a modern connection" are both false for logging now, and the PR's own description labels this a breaking change.
Concrete walk-through. A reader of deprecated.md builds the default in-process client, which negotiates 2026-07-28: (1) async with Client(server, logging_callback=on_log) — no log_level=, since deprecated.md told them logging keeps working with only a warning attached; (2) a tool calls ctx.info("progress"); (3) ServerSession._allowed_log_levels is the empty frozenset (no _meta opt-in on the request), so the notification is dropped with only a server-side debug log; (4) on_log never fires, and nothing in the page they consulted explains why. Meanwhile the PR-updated callbacks.md says the opposite ("the callback alone gets you nothing") — the docs now disagree with each other about the same behavior.
Why nothing else catches it. The PR's doc updates went to callbacks.md and migration.md only. AGENTS.md requires updating the relevant page(s) under docs/ in the same PR when a change affects user-visible behavior, and deprecated.md is the page dedicated to exactly this feature set's runtime behavior. This is distinct from the other findings on this PR (an example-server comment, the ServerSession docstring): fixing those leaves these two pages still asserting logging is unaffected at the wire.
How to fix. A short paragraph in deprecated.md's warning box noting that at 2026-07-28 protocol logging is additionally gated per-request — dropped unless the request opts in via the reserved _meta key / Client(log_level=...) — plus a matching clause in the Recap and in whats-new.md, each linking to this migration section ("Log messages are delivered only to requests that opt in").
Severity. Nit: docs-only, nothing breaks functionally at merge, and the behavior is documented correctly in migration.md and callbacks.md — this is about the two stale pages contradicting them.
| # Protocol logging is deprecated (SEP-2577), so the raw notification | ||
| # keeps this warning-free. On 2026-07-28+ the client only receives it | ||
| # because it opts in with `log_level=`; `related_request_id` keeps it on | ||
| # this request's response stream (matters over streamable HTTP). |
There was a problem hiding this comment.
🟡 The rewritten comment claims the client "only receives it because it opts in with log_level=", but this raw ctx.request_context.session.send_notification(LoggingMessageNotification(...)) path bypasses the PR's gate entirely — ServerSession.send_notification → _notify has no _allowed_log_levels check (the gate lives only in send_log_message / Context.log / Connection.log), so on a 2026 connection the notification is delivered whether or not the request opted in, and the client's new log_level="info" is decorative for this story. Either route through the gated send_log_message or fix the comment to say the raw send bypasses the opt-in; relatedly, examples/stories/streaming/README.md still quotes the pre-PR Client(target, mode=mode, logging_callback=on_log) construction with no log_level=.
Extended reasoning...
The bug. This PR rewrites the comment above the raw log send to read: "On 2026-07-28+ the client only receives it because it opts in with log_level=". That causal claim is false for this code path. The example emits the log entry via ctx.request_context.session.send_notification(types.LoggingMessageNotification(...), related_request_id=...) — the raw, un-gated notification path — so the client receives it regardless of whether the request carried the io.modelcontextprotocol/logLevel opt-in. The new log_level="info" added to examples/stories/streaming/client.py in this same PR does not influence delivery for this story at all.
The code path. ServerSession.send_notification delegates to _notify (src/mcp/server/session.py:127-135), which dumps the model and calls channel.notify directly — there is no _allowed_log_levels check anywhere on that path. The per-request gate this PR introduces lives exclusively in three places: ServerSession.send_log_message, Context.log, and Connection.log (each checks level not in allowed_log_levels(...) before sending). A grep over src/mcp confirms nothing downstream filters notifications/message either: neither the dispatchers nor Connection.notify inspect the method. On the client side, notifications/message is a defined server notification at 2026-07-28, so ClientSession._on_notify parses it and invokes logging_callback unconditionally — the client never enforces its own opt-in.
Step-by-step proof. (1) Remove log_level="info" from the streaming story's Client(...) call and run the story on a 2026 connection. (2) The countdown tool calls ctx.request_context.session.send_notification(LoggingMessageNotification(...), related_request_id=...). (3) send_notification calls _notify(notification, request_scoped=True), which goes straight to channel.notify("notifications/message", params) — no gate consulted, because _allowed_log_levels is only read inside send_log_message. (4) The client's _on_notify parses the notification and awaits on_log, so logs fills exactly as before. The story's assertions pass with or without the opt-in — proving the comment's "only receives it because it opts in" is backwards. (Contrast: change the tool to await ctx.session.send_log_message("info", ...) and the un-opted run delivers nothing, which is the behavior the comment describes.)
Why it matters. The example teaches the opposite of how the mechanism works. A reader copying this pattern believing the SDK gates the raw path will ship a 2026 server that sends notifications/message for requests that never opted in — violating the spec's MUST NOT ("the server MUST NOT emit notifications/message for a request whose _meta lacks io.modelcontextprotocol/logLevel"). server_lowlevel.py in the same story uses the identical ungated raw send. As shipped the story happens to be spec-compliant on the wire only because the client does opt in at "info" and the messages are "info"-level — but that compliance is supplied by the client, not enforced by the server as the comment implies.
Related staleness. examples/stories/streaming/README.md still quotes the pre-PR construction Client(target, mode=mode, logging_callback=on_log) with no log_level=, which no longer matches client.py, and its "What to look at" prose describing the raw-send rationale predates the gate.
How to fix. Either (a) switch the story's servers to the gated helper — await ctx.session.send_log_message("info", f"step {i}/{steps}", logger="countdown") with the deprecation pragma, as this PR's own interaction tests do — so the comment becomes true and the example demonstrates the opt-in end to end; or (b) keep the raw send and reword the comment to say the raw notification path bypasses the per-request gate (the server must honor the opt-in itself when using it). Update the streaming README's client snippet to include log_level="info" in the same pass. Severity is nit: the story runs and asserts correctly either way, so nothing breaks at merge — the harm is a misleading comment/README introduced by this PR's own rewrite.
…logging test A notification carries no request to opt in and no response stream to carry a log entry, so its _meta no longer opens the per-request log gate: _make_context passes the log-level opt-in to ServerSession only for requests. Regression-tested over the dual-era stream loop. Merge the opt-in and threshold interaction tests into one two-phase test sharing a real closure callback instead of an AsyncMock.
| """Send a log message notification. | ||
|
|
||
| On 2026-07-28+ delivery is a per-request opt-in: nothing is sent | ||
| unless this request's `_meta` carried the reserved log-level key, and | ||
| entries below the requested level are dropped (debug-logged). What is | ||
| sent rides this request's stream regardless of `related_request_id` - | ||
| the spec forbids `notifications/message` on any stream but the one | ||
| carrying the response. Handshake versions send unconditionally on the | ||
| channel `related_request_id` selects, as before. | ||
| """ |
There was a problem hiding this comment.
🟡 MCPServer's Context.log docstring (and its debug/info/warning/error helpers in src/mcp/server/mcpserver/context.py) still promises unconditional delivery — "Send a log message to the client." — but it delegates to this now-gated send_log_message, so on 2026-07-28 connections (including the default in-process Client(server)) those calls are silently dropped unless the request opted in via io.modelcontextprotocol/logLevel. Consider adding the same one-or-two-line caveat this PR added here (and to the lowlevel Context.log and Connection.log) to MCPServer's Context.log; the four helpers delegate to it, so a caveat there suffices.
Extended reasoning...
What the bug is. This PR adds a per-request opt-in gate to 2026-07-28 log delivery and documents it in the docstrings of all three lower-level emitters: ServerSession.send_log_message (this hunk), the lowlevel Context.log in src/mcp/server/context.py, and Connection.log in src/mcp/server/connection.py — each now says "On 2026-07-28+ delivery is a per-request opt-in: nothing is sent unless...". But the most user-facing logging API, MCPServer's Context.log at src/mcp/server/mcpserver/context.py:254-269 (untouched by this PR), keeps its pre-PR docstring, which states unconditionally "Send a log message to the client." The debug/info/warning/error convenience helpers (lines 363-380) carry equally unconditional one-liners ("Send a debug log message.", etc.).\n\nThe code path. MCPServer's Context.log delegates straight to request_context.session.send_log_message(...) — the method gated in this hunk. The PR's runner.py change builds every per-request ServerSession with request_meta=meta, so _allowed_log_levels = allowed_log_levels(protocol_version, request_meta). On a modern (2026-07-28+) connection with no io.modelcontextprotocol/logLevel key in the request's _meta, that set is frozenset() and every level is debug-logged and dropped.\n\nWhy this is a PR-introduced inconsistency, not pre-existing drift. Before this PR the docstring was accurate: send_log_message sent unconditionally on every connection. The PR changes the runtime behavior and updates the docstrings of the three other emitters plus docs/migration.md and docs/client/callbacks.md — the migration guide even names exactly this surface: "ctx.info(...) and friends on MCPServer's Context ... are silently dropped". Only the MCPServer Context docstrings were skipped. The PR's own interaction test test_context_logging_helpers_send_log_notifications had to add log_level=\"debug\" for ctx.debug/info/warning/error to deliver at all, confirming the gate applies to this path.\n\nStep-by-step proof.\n1. async with Client(server, logging_callback=on_log) as client — the default in-process client negotiates 2026-07-28 via mode=\"auto\"; no log_level= means no opt-in is stamped.\n2. A tool calls await ctx.info(\"progress\") — per the docstring chain ("Send an info log message." → "Send a log message to the client."), the user expects delivery.\n3. Context.log calls session.send_log_message(\"info\", ...); \"info\" not in self._allowed_log_levels (empty frozenset, since the request's _meta carried no io.modelcontextprotocol/logLevel), so the notification is debug-logged and dropped.\n4. on_log never fires — the opposite of what the docstring the user read in their IDE promised.\n\nWhy nothing else catches it. IDE hover/autocomplete shows the docstring, not docs/migration.md; a user of ctx.info(...) never sees the caveat the PR placed on ServerSession.send_log_message unless they read through the delegation. AGENTS.md requires accurate docstrings on public APIs, and this one is now factually wrong about the SDK's most prominent logging surface.\n\nHow to fix. Add the same short caveat this PR added to the other three emitters to MCPServer Context.log's docstring — e.g. "On 2026-07-28+ delivery is a per-request opt-in: nothing is sent unless this request's _meta carried the reserved log-level key, and entries below the requested level are dropped. Handshake versions send unconditionally, as before." The four helpers delegate to log, so a one-line pointer (or nothing) suffices there. Documentation-only — nothing breaks at runtime, hence nit rather than blocking.\n\n(Distinct from the existing comment on the ServerSession class docstring's related_request_id routing contract: that is a different class and a different statement; fixing one does not fix the other.)
Gate
notifications/messageon the per-request log-level opt-in at 2026-07-28.Motivation and Context
The 2026-07-28 spec makes log delivery a per-request opt-in (
server/utilities/logging): the server MUST NOT emitnotifications/messagefor a request whose_metalacksio.modelcontextprotocol/logLevel, may send only at or above the requested level when present, and MUST NOT deliver on any stream other than the one carrying that request's response. The SDK had no gating:ServerSession.send_log_message,Context.log, andConnection.logsent unconditionally on modern connections (observable on both stdio and streamable HTTP).The gate lives where the request's
_metameets the connection's era:allowed_log_levels(protocol_version, meta)inserver/connection.pyis the one place that decides which levels may be sent (severity ordering derived from theLoggingLevelliteral, no second table), andServerSession— built once per inbound request by_make_context— fixes that set at construction, so every emit path funnels through it. On 2026 connections logs also ride the requesting stream by construction (related_request_idno longer selects the standalone stream there), andConnection.log— which has no request to opt in — never sends. Handshake-era (≤2025-11-25) connections keep theirlogging/setLevelsemantics unchanged. An unrecognizedlogLevelvalue on a spec method was already rejected with-32602at surface validation; this pins it with a test.Since 2026 servers only log to requests that ask, the client needs a way to ask:
Client(..., log_level="info")stamps the reserved key on every modern request (a per-requestmeta=entry overrides it), so alogging_callbackon a 2026 connection actually receives messages.How Has This Been Tested?
Interaction suite: the existing log-delivery requirements pass on both eras (the 2026 cells now opt in), plus three new 2026-only requirements — no opt-in ⇒ nothing sent, opt-in ⇒ threshold filtering, unrecognized level ⇒
-32602— superseding the removedlogging/setLevelentries. Unit tests for the gate onServerSession,Context,Connection, and the client stamp. Also driven end-to-end over a real stdio subprocess with hand-written 2026 envelopes: no-key request produces only the response,logLevel: "info"gets the notification before the result on the same stream, a below-threshold call sends nothing, and"verbose"is rejected with-32602.Breaking Changes
On 2026-07-28 connections — including the default in-process
Client(server)— server log calls (ctx.info(...),ctx.session.send_log_message(...), ...) are dropped unless the request opted in, so alogging_callbackthat used to receive everything receives nothing until the client passeslog_level=. Documented under the migration guide's 2026-era notes. Nothing changes for 2025-11-25 and earlier connections.Types of changes
Checklist
Additional context
Mirrors the typescript-sdk's era-scoped gate in
ctx.mcpReq.log(modern: threshold from the request envelope, absent ⇒ suppress; legacy: session-levelsetLevel).AI Disclaimer