Skip to content

Commit 89c5e70

Browse files
authored
Gate log notifications on the per-request log-level opt-in at 2026-07-28 (#3198)
1 parent b61ce38 commit 89c5e70

21 files changed

Lines changed: 406 additions & 31 deletions

docs/client/callbacks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`.
133133

134134
Two more. Neither declares anything.
135135

136-
`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
136+
`logging_callback` receives the `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it. On a 2026-era connection the callback alone gets you nothing, because 2026 servers send log messages only to requests that opt in: pass `log_level="info"` (or another level) to `Client(...)` to stamp that opt-in on every request and receive that level and above. Pre-2026 servers ignore it and keep their `logging/setLevel` behavior.
137137

138138
`message_handler` is the catch-all: every server notification the session surfaces reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. Two never do: `notifications/cancelled` is applied by the SDK rather than surfaced, and a subscription acknowledgment for a live `listen()` stream is consumed by that stream. Annotate the parameter with `IncomingMessage` (`ServerNotification | Exception`, exported from `mcp.client`). The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
139139

docs/migration.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1710,7 +1710,7 @@ The parametrized `Context[MyLifespanState]` annotation currently works only on `
17101710

17111711
`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers — `create_message`, `elicit` / `elicit_form` / `elicit_url`, `send_elicit_complete`, `list_roots`, `send_log_message`, `send_resource_updated`, `send_resource_list_changed` / `send_tool_list_changed` / `send_prompt_list_changed`, `send_ping`, `send_progress_notification`, and the new `report_progress` — plus `check_client_capability` and the read-only `client_params`, `client_capabilities`, `protocol_version`, and `can_send_request` properties. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.
17121712

1713-
The helpers keep their v1 signatures, so calls through `ctx.session` are source-compatible: `send_notification(notification, related_request_id=None)`, `send_log_message(level, data, logger=None, related_request_id=None)` (now [SEP-2577-deprecated](#roots-sampling-and-logging-methods-deprecated-sep-2577)), `send_progress_notification(progress_token, progress, total=None, message=None, related_request_id=None)`, `related_request_id=` on `elicit_form` / `elicit_url` / `send_elicit_complete`, and `metadata=ServerMessageMetadata(related_request_id=...)` on `send_request` (used by `create_message`). As in v1, a present `related_request_id` routes the message onto that request's own stream (the POST response in streamable HTTP) and an absent one uses the connection's standalone stream. Two adjustments: `send_resource_updated(uri)` accepts `str | AnyUrl`, and `send_notification` takes the notification model itself — the `types.ServerNotification(...)` wrapper is gone with the other `RootModel` unions (`await session.send_notification(types.ResourceListChangedNotification())`; see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)).
1713+
The helpers keep their v1 signatures, so calls through `ctx.session` are source-compatible: `send_notification(notification, related_request_id=None)`, `send_log_message(level, data, logger=None, related_request_id=None)` (now [SEP-2577-deprecated](#roots-sampling-and-logging-methods-deprecated-sep-2577)), `send_progress_notification(progress_token, progress, total=None, message=None, related_request_id=None)`, `related_request_id=` on `elicit_form` / `elicit_url` / `send_elicit_complete`, and `metadata=ServerMessageMetadata(related_request_id=...)` on `send_request` (used by `create_message`). As in v1, a present `related_request_id` routes the message onto that request's own stream (the POST response in streamable HTTP) and an absent one uses the connection's standalone stream — the one 2026-era exception being `send_log_message`, whose delivery is gated and request-scoped by the spec there (see [Log messages are delivered only to requests that opt in](#log-messages-are-delivered-only-to-requests-that-opt-in)). Two adjustments: `send_resource_updated(uri)` accepts `str | AnyUrl`, and `send_notification` takes the notification model itself — the `types.ServerNotification(...)` wrapper is gone with the other `RootModel` unions (`await session.send_notification(types.ResourceListChangedNotification())`; see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)).
17141714

17151715
Behavior changes:
17161716

@@ -2840,6 +2840,19 @@ async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_c
28402840

28412841
The client's same `elicitation_callback` answers both; the resolver lets the server *return* the question instead of pushing it.
28422842

2843+
### Log messages are delivered only to requests that opt in
2844+
2845+
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.
2846+
2847+
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:
2848+
2849+
```python
2850+
async with Client(server, logging_callback=on_log, log_level="info") as client:
2851+
await client.call_tool("chatty", {}) # info and above reach `on_log`
2852+
```
2853+
2854+
`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.
2855+
28432856
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
28442857

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

examples/stories/streaming/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
1515
async def on_log(params: LoggingMessageNotificationParams) -> None:
1616
logs.append(params)
1717

18-
async with Client(target, mode=mode, logging_callback=on_log) as client:
18+
# `log_level` is the 2026-07-28 per-request opt-in: without it a modern server
19+
# sends no log notifications at all (pre-2026 servers ignore it and send anyway).
20+
async with Client(target, mode=mode, logging_callback=on_log, log_level="info") as client:
1921
# ── progress + logging: a short countdown delivers exactly `steps` of each, in order ──
2022
updates: list[tuple[float, float | None, str | None]] = []
2123

examples/stories/streaming/server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ async def countdown(steps: int, ctx: Context) -> dict[str, int]:
1616
try:
1717
for i in range(1, steps + 1):
1818
await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}")
19-
# No non-deprecated logging helper on Context yet, so send the raw
20-
# notification. `related_request_id` keeps it on this request's response
21-
# stream (matters over streamable HTTP).
19+
# Protocol logging is deprecated (SEP-2577), so the raw notification
20+
# keeps this warning-free. On 2026-07-28+ the client only receives it
21+
# because it opts in with `log_level=`; `related_request_id` keeps it on
22+
# this request's response stream (matters over streamable HTTP).
2223
await ctx.request_context.session.send_notification(
2324
types.LoggingMessageNotification(
2425
params=types.LoggingMessageNotificationParams(

src/mcp/client/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,16 @@ async def main():
312312
logging_callback: LoggingFnT | None = None
313313
"""Callback for handling logging notifications."""
314314

315+
log_level: LoggingLevel | None = None
316+
"""The log level to opt in to on 2026-07-28+ connections (deprecated logging feature, SEP-2577).
317+
318+
Modern (2026-07-28+) servers send `notifications/message` only for requests that opt in by
319+
carrying `io.modelcontextprotocol/logLevel` in `_meta`, and only at or above that level. Setting
320+
this stamps that opt-in on every request; `None` (the default) means no opt-in, so no log
321+
messages arrive - a `logging_callback` alone is not an opt-in. No effect on handshake-era
322+
connections, where the deprecated `logging/setLevel` request governs delivery instead. A
323+
per-request `_meta` entry with the same key overrides this default."""
324+
315325
# TODO(Marcelo): Why do we have both "callback" and "handler"?
316326
message_handler: MessageHandlerFnT | None = None
317327
"""Callback for handling raw messages."""
@@ -425,6 +435,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
425435
sampling_capabilities=self.sampling_capabilities,
426436
list_roots_callback=self.list_roots_callback,
427437
logging_callback=self.logging_callback,
438+
log_level=self.log_level,
428439
message_handler=message_handler,
429440
client_info=self.client_info,
430441
elicitation_callback=self.elicitation_callback,

src/mcp/client/session.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
CLIENT_INFO_META_KEY,
2020
CONNECTION_CLOSED,
2121
INTERNAL_ERROR,
22+
LOG_LEVEL_META_KEY,
2223
METHOD_NOT_FOUND,
2324
PROTOCOL_VERSION_META_KEY,
2425
SERVER_INFO_META_KEY,
@@ -121,13 +122,20 @@ def _make_modern_stamp(
121122
client_info: dict[str, Any],
122123
capabilities: dict[str, Any],
123124
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
125+
*,
126+
log_level: types.LoggingLevel | None = None,
124127
) -> Callable[[dict[str, Any], CallOptions], None]:
125128
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
126129
params = data.setdefault("params", {})
127130
meta = params.setdefault("_meta", {})
128131
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
129132
meta[CLIENT_INFO_META_KEY] = client_info
130133
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
134+
# The per-request log-delivery opt-in (2026 logging is opt-in per
135+
# request). A default the caller can override on any single call by
136+
# supplying the key in that request's `_meta`, hence setdefault.
137+
if log_level is not None:
138+
meta.setdefault(LOG_LEVEL_META_KEY, log_level)
131139
# `cancel_on_abandon` stays at the dispatcher default (True): the
132140
# courtesy `notifications/cancelled` is the abandon signal. On the
133141
# stream transports it is the 2026 wire's cancellation spelling; the
@@ -372,6 +380,7 @@ def __init__(
372380
message_handler: MessageHandlerFnT | None = None,
373381
client_info: types.Implementation | None = None,
374382
*,
383+
log_level: types.LoggingLevel | None = None,
375384
sampling_capabilities: types.SamplingCapability | None = None,
376385
extensions: dict[str, dict[str, Any]] | None = None,
377386
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
@@ -393,6 +402,7 @@ def __init__(
393402
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
394403
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
395404
self._logging_callback = logging_callback or _default_logging_callback
405+
self._log_level: types.LoggingLevel | None = log_level
396406
self._message_handler = message_handler or _default_message_handler
397407
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
398408
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
@@ -645,7 +655,9 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
645655
version = mutual[-1]
646656
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
647657
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
648-
self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers)
658+
self._stamp = _make_modern_stamp(
659+
version, client_info, capabilities, self._resolve_param_headers, log_level=self._log_level
660+
)
649661
self._discover_result = result
650662
self._discover_server_info = _parse_server_info_stamp(result)
651663
self._initialize_result = None

src/mcp/server/connection.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@
2222
import logging
2323
from collections.abc import Mapping
2424
from contextlib import AsyncExitStack
25-
from typing import Any, TypeVar, overload
25+
from typing import Any, Final, TypeVar, get_args, overload
2626

2727
import anyio
2828
from mcp_types import (
29+
LOG_LEVEL_META_KEY,
2930
ClientCapabilities,
3031
CreateMessageRequest,
3132
CreateMessageResult,
@@ -41,7 +42,7 @@
4142
Request,
4243
)
4344
from mcp_types import methods as _methods
44-
from mcp_types.version import LATEST_HANDSHAKE_VERSION
45+
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
4546
from pydantic import BaseModel, ValidationError
4647
from typing_extensions import deprecated
4748

@@ -52,6 +53,40 @@
5253
__all__ = ["Connection"]
5354

5455
logger = logging.getLogger(__name__)
56+
# `Connection.log`'s `logger` parameter (public API, the spec's logger-name
57+
# field) shadows the module logger inside that method; this alias keeps the
58+
# module logger reachable there.
59+
_logger = logger
60+
61+
_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel)
62+
"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the
63+
RFC 5424 scale) - the literal is the single source of the ordering."""
64+
65+
_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS)
66+
67+
68+
def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]:
69+
"""The `notifications/message` levels deliverable for one inbound request.
70+
71+
2026-07-28+ makes log delivery a per-request opt-in (server/utilities/
72+
logging): the client sets the reserved `io.modelcontextprotocol/logLevel`
73+
`_meta` key, absent means no levels - the server MUST NOT send - and
74+
present means that level and above. An unrecognized value reads as absent;
75+
spec methods already reject a malformed value at surface validation
76+
before any handler runs, so that arm only serves custom methods, where
77+
dropping is the safe direction. Connection-scoped emitters pass
78+
`meta=None`: `logging/setLevel` is gone at 2026 and log delivery is
79+
request-scoped only, so they deliver nothing. Handshake versions keep
80+
their `logging/setLevel`-era semantics: every level may be sent, filtering
81+
is the application's `logging/setLevel` handler's job as before.
82+
"""
83+
if protocol_version not in MODERN_PROTOCOL_VERSIONS:
84+
return _ALL_LOG_LEVELS
85+
requested = (meta or {}).get(LOG_LEVEL_META_KEY)
86+
if requested not in _LOG_LEVELS:
87+
return frozenset()
88+
return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :])
89+
5590

5691
ResultT = TypeVar("ResultT", bound=BaseModel)
5792

@@ -373,7 +408,17 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non
373408

374409
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
375410
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
376-
"""Send a `notifications/message` log entry on the standalone stream. Best-effort."""
411+
"""Send a `notifications/message` log entry on the standalone stream. Best-effort.
412+
413+
On 2026-07-28+ connections this never sends: log delivery is a
414+
per-request opt-in that rides the requesting stream (`ctx.log`,
415+
`ctx.session.send_log_message`), and the standalone stream is
416+
forbidden from carrying `notifications/message`, so the entry is
417+
debug-logged and dropped.
418+
"""
419+
if level not in allowed_log_levels(self.protocol_version, None):
420+
_logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version)
421+
return
377422
params: dict[str, Any] = {"level": level, "data": data}
378423
if logger is not None:
379424
params["logger"] = logger

0 commit comments

Comments
 (0)