Skip to content

Commit 72ddad4

Browse files
committed
Gate log notifications on the per-request log-level opt-in at 2026-07-28
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.
1 parent 923341c commit 72ddad4

18 files changed

Lines changed: 384 additions & 28 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
@@ -1689,7 +1689,7 @@ The parametrized `Context[MyLifespanState]` annotation currently works only on `
16891689

16901690
`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`.
16911691

1692-
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)).
1692+
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)).
16931693

16941694
Behavior changes:
16951695

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

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

2822+
### Log messages are delivered only to requests that opt in
2823+
2824+
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.
2825+
2826+
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:
2827+
2828+
```python
2829+
async with Client(server, logging_callback=on_log, log_level="info") as client:
2830+
await client.call_tool("chatty", {}) # info and above reach `on_log`
2831+
```
2832+
2833+
`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.
2834+
28222835
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
28232836

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

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: 49 additions & 4 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,17 +42,51 @@
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

4849
from mcp.shared.dispatcher import CallOptions, Outbound
4950
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
5051
from mcp.shared.peer import Meta, dump_params
5152

52-
__all__ = ["Connection"]
53+
__all__ = ["Connection", "allowed_log_levels"]
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

src/mcp/server/context.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from collections.abc import Awaitable, Callable, Mapping
23
from dataclasses import dataclass
34
from typing import Any, Generic, Protocol
@@ -6,7 +7,7 @@
67
from pydantic import BaseModel
78
from typing_extensions import TypeVar, deprecated
89

9-
from mcp.server.connection import Connection
10+
from mcp.server.connection import Connection, allowed_log_levels
1011
from mcp.server.session import ServerSession
1112
from mcp.shared.context import BaseContext
1213
from mcp.shared.dispatcher import DispatchContext
@@ -15,6 +16,12 @@
1516
from mcp.shared.peer import Meta
1617
from mcp.shared.transport_context import TransportContext
1718

19+
logger = logging.getLogger(__name__)
20+
# `Context.log`'s `logger` parameter (public API, the spec's logger-name
21+
# field) shadows the module logger inside that method; this alias keeps it
22+
# reachable there.
23+
_logger = logger
24+
1825
# Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan.
1926
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
2027
RequestT = TypeVar("RequestT", default=Any)
@@ -67,6 +74,9 @@ def __init__(
6774
super().__init__(dctx, meta=meta)
6875
self._lifespan = lifespan
6976
self._connection = connection
77+
# Same per-request log gate as `ServerSession`: fixed at construction
78+
# from this request's `_meta` log-level opt-in and the connection's era.
79+
self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta)
7080

7181
@property
7282
def lifespan(self) -> LifespanT_co:
@@ -102,7 +112,15 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *
102112
Uses this request's back-channel (so the entry rides the request's SSE
103113
stream in streamable HTTP), not the standalone stream - use
104114
`ctx.connection.log(...)` for that.
115+
116+
On 2026-07-28+ delivery is a per-request opt-in: nothing is sent
117+
unless this request's `_meta` carried the reserved log-level key, and
118+
entries below the requested level are dropped (debug-logged).
119+
Handshake versions send unconditionally, as before.
105120
"""
121+
if level not in self._allowed_log_levels:
122+
_logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level)
123+
return
106124
params: dict[str, Any] = {"level": level, "data": data}
107125
if logger is not None:
108126
params["logger"] = logger

src/mcp/server/runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,8 @@ def _make_context(
321321
# Per-request session: `dctx` is the request-scoped channel (auto-threads
322322
# its own request_id on streamable HTTP); the standalone channel is read
323323
# off `connection.outbound`. `related_request_id` on the public API selects.
324-
session = ServerSession(dctx, self.connection)
324+
# `meta` carries this request's log-level opt-in for the session's log gate.
325+
session = ServerSession(dctx, self.connection, request_meta=meta)
325326
return ServerRequestContext(
326327
session=session,
327328
lifespan_context=self.lifespan_state,

0 commit comments

Comments
 (0)