Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/client/callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`.

Two more. Neither declares anything.

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

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

Expand Down
15 changes: 14 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1689,7 +1689,7 @@ The parametrized `Context[MyLifespanState]` annotation currently works only on `

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

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

Behavior changes:

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

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

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

Comment on lines +2822 to +2834

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.

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

### 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
11 changes: 11 additions & 0 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,16 @@ async def main():
logging_callback: LoggingFnT | None = None
"""Callback for handling logging notifications."""

log_level: LoggingLevel | None = None
"""The log level to opt in to on 2026-07-28+ connections (deprecated logging feature, SEP-2577).

Modern (2026-07-28+) servers send `notifications/message` only for requests that opt in by
carrying `io.modelcontextprotocol/logLevel` in `_meta`, and only at or above that level. Setting
this stamps that opt-in on every request; `None` (the default) means no opt-in, so no log
messages arrive - a `logging_callback` alone is not an opt-in. No effect on handshake-era
connections, where the deprecated `logging/setLevel` request governs delivery instead. A
per-request `_meta` entry with the same key overrides this default."""

# TODO(Marcelo): Why do we have both "callback" and "handler"?
message_handler: MessageHandlerFnT | None = None
"""Callback for handling raw messages."""
Expand Down Expand Up @@ -425,6 +435,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
sampling_capabilities=self.sampling_capabilities,
list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback,
log_level=self.log_level,
message_handler=message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
Expand Down
14 changes: 13 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
CLIENT_INFO_META_KEY,
CONNECTION_CLOSED,
INTERNAL_ERROR,
LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
Expand Down Expand Up @@ -121,14 +122,21 @@
client_info: dict[str, Any],
capabilities: dict[str, Any],
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
*,
log_level: types.LoggingLevel | None = None,
) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
params = data.setdefault("params", {})
meta = params.setdefault("_meta", {})
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
meta[CLIENT_INFO_META_KEY] = client_info
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
# The per-request log-delivery opt-in (2026 logging is opt-in per
# request). A default the caller can override on any single call by
# supplying the key in that request's `_meta`, hence setdefault.
if log_level is not None:
meta.setdefault(LOG_LEVEL_META_KEY, log_level)
# `cancel_on_abandon` stays at the dispatcher default (True): the

Check warning on line 139 in src/mcp/client/session.py

View check run for this annotation

Claude / Claude Code Review

Per-request log opt-out is impossible once Client(log_level=...) is set

Once `Client(log_level=...)` is set, a single request cannot opt out of log delivery: the natural spelling `meta={LOG_LEVEL_META_KEY: None}` is stripped by `model_dump(exclude_none=True)` in `send_request` before the stamp runs, so `meta.setdefault(LOG_LEVEL_META_KEY, log_level)` re-applies the client-wide opt-in — contradicting the documented promise that a per-request `_meta` entry overrides the default. Consider checking the caller's original `meta` for key presence (treating an explicit `Non
Comment thread
maxisbey marked this conversation as resolved.
# courtesy `notifications/cancelled` is the abandon signal. On the
# stream transports it is the 2026 wire's cancellation spelling; the
# streamable-HTTP transport translates it into aborting the request's
Expand Down Expand Up @@ -372,6 +380,7 @@
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
*,
log_level: types.LoggingLevel | None = None,
sampling_capabilities: types.SamplingCapability | None = None,
extensions: dict[str, dict[str, Any]] | None = None,
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
Expand All @@ -393,6 +402,7 @@
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback
self._log_level: types.LoggingLevel | None = log_level
Comment thread
maxisbey marked this conversation as resolved.
self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
Expand Down Expand Up @@ -645,7 +655,9 @@
version = mutual[-1]
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers)
self._stamp = _make_modern_stamp(
version, client_info, capabilities, self._resolve_param_headers, log_level=self._log_level
)
self._discover_result = result
self._discover_server_info = _parse_server_info_stamp(result)
self._initialize_result = None
Expand Down
53 changes: 49 additions & 4 deletions src/mcp/server/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
import logging
from collections.abc import Mapping
from contextlib import AsyncExitStack
from typing import Any, TypeVar, overload
from typing import Any, Final, TypeVar, get_args, overload

import anyio
from mcp_types import (
LOG_LEVEL_META_KEY,
ClientCapabilities,
CreateMessageRequest,
CreateMessageResult,
Expand All @@ -41,17 +42,51 @@
Request,
)
from mcp_types import methods as _methods
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
from pydantic import BaseModel, ValidationError
from typing_extensions import deprecated

from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
from mcp.shared.peer import Meta, dump_params

__all__ = ["Connection"]
__all__ = ["Connection", "allowed_log_levels"]

logger = logging.getLogger(__name__)
# `Connection.log`'s `logger` parameter (public API, the spec's logger-name
# field) shadows the module logger inside that method; this alias keeps the
# module logger reachable there.
_logger = logger

_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel)
"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the
RFC 5424 scale) - the literal is the single source of the ordering."""

_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS)


def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]:
"""The `notifications/message` levels deliverable for one inbound request.

2026-07-28+ makes log delivery a per-request opt-in (server/utilities/
logging): the client sets the reserved `io.modelcontextprotocol/logLevel`
`_meta` key, absent means no levels - the server MUST NOT send - and
present means that level and above. An unrecognized value reads as absent;
spec methods already reject a malformed value at surface validation
before any handler runs, so that arm only serves custom methods, where
dropping is the safe direction. Connection-scoped emitters pass
`meta=None`: `logging/setLevel` is gone at 2026 and log delivery is
request-scoped only, so they deliver nothing. Handshake versions keep
their `logging/setLevel`-era semantics: every level may be sent, filtering
is the application's `logging/setLevel` handler's job as before.
"""
if protocol_version not in MODERN_PROTOCOL_VERSIONS:
return _ALL_LOG_LEVELS
requested = (meta or {}).get(LOG_LEVEL_META_KEY)
if requested not in _LOG_LEVELS:
return frozenset()
return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :])

Check warning on line 89 in src/mcp/server/connection.py

View check run for this annotation

Claude / Claude Code Review

No matching conformance-suite test for the 2026 per-request log gate (AGENTS.md requirement)

AGENTS.md requires new 2026-07-28 spec features to have a matching conformance-suite test, and to flag it explicitly when none exists — but the pinned harness (@modelcontextprotocol/conformance@0.2.0-alpha.10) has no scenario for the per-request `io.modelcontextprotocol/logLevel` opt-in gate this PR implements. Consider adding a note to the PR (or a follow-up issue on the conformance repo) that the opt-in / threshold / -32602 behaviors are currently covered only by the SDK's own interaction suit
Comment thread
maxisbey marked this conversation as resolved.

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

Expand Down Expand Up @@ -373,7 +408,17 @@

@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
"""Send a `notifications/message` log entry on the standalone stream. Best-effort."""
"""Send a `notifications/message` log entry on the standalone stream. Best-effort.

On 2026-07-28+ connections this never sends: log delivery is a
per-request opt-in that rides the requesting stream (`ctx.log`,
`ctx.session.send_log_message`), and the standalone stream is
forbidden from carrying `notifications/message`, so the entry is
debug-logged and dropped.
"""
if level not in allowed_log_levels(self.protocol_version, None):
_logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version)
return
params: dict[str, Any] = {"level": level, "data": data}
if logger is not None:
params["logger"] = logger
Expand Down
20 changes: 19 additions & 1 deletion src/mcp/server/context.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any, Generic, Protocol
Expand All @@ -6,7 +7,7 @@
from pydantic import BaseModel
from typing_extensions import TypeVar, deprecated

from mcp.server.connection import Connection
from mcp.server.connection import Connection, allowed_log_levels
from mcp.server.session import ServerSession
from mcp.shared.context import BaseContext
from mcp.shared.dispatcher import DispatchContext
Expand All @@ -15,6 +16,12 @@
from mcp.shared.peer import Meta
from mcp.shared.transport_context import TransportContext

logger = logging.getLogger(__name__)
# `Context.log`'s `logger` parameter (public API, the spec's logger-name
# field) shadows the module logger inside that method; this alias keeps it
# reachable there.
_logger = logger

# Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan.
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
RequestT = TypeVar("RequestT", default=Any)
Expand Down Expand Up @@ -67,6 +74,9 @@ def __init__(
super().__init__(dctx, meta=meta)
self._lifespan = lifespan
self._connection = connection
# Same per-request log gate as `ServerSession`: fixed at construction
# from this request's `_meta` log-level opt-in and the connection's era.
self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta)

@property
def lifespan(self) -> LifespanT_co:
Expand Down Expand Up @@ -102,7 +112,15 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *
Uses this request's back-channel (so the entry rides the request's SSE
stream in streamable HTTP), not the standalone stream - use
`ctx.connection.log(...)` for that.

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).
Handshake versions send unconditionally, as before.
"""
if level not in self._allowed_log_levels:
_logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level)
return
params: dict[str, Any] = {"level": level, "data": data}
if logger is not None:
params["logger"] = logger
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,12 @@
close_standalone_sse_stream = md.close_standalone_sse_stream
else:
request = close_sse_stream = close_standalone_sse_stream = None
# Per-request session: `dctx` is the request-scoped channel (auto-threads
# its own request_id on streamable HTTP); the standalone channel is read
# off `connection.outbound`. `related_request_id` on the public API selects.
session = ServerSession(dctx, self.connection)
# `meta` carries this request's log-level opt-in for the session's log gate.
session = ServerSession(dctx, self.connection, request_meta=meta)
return ServerRequestContext(

Check warning on line 326 in src/mcp/server/runner.py

View check run for this annotation

Claude / Claude Code Review

Inbound notifications open the 2026 log gate (logLevel stamped on and honored for notifications)

The per-request log-level opt-in leaks onto inbound *notifications*: `_make_modern_stamp` stamps `io.modelcontextprotocol/logLevel` on client notifications too (the spec defines the key on request params only), and server-side `_make_context` passes any inbound message's `_meta` to `ServerSession`, so a notification handler's `ctx.session.send_log_message(...)` passes the gate and emits a `notifications/message` correlated to no request. Consider stamping the key only on requests client-side and
Comment thread
maxisbey marked this conversation as resolved.
session=session,
lifespan_context=self.lifespan_state,
method=method,
Expand Down
Loading
Loading