Gate log notifications on the per-request log-level opt-in at 2026-07-28 (#3198)

This commit is contained in:
Max 2026-07-28 02:20:33 +01:00 • committed by GitHub
commit 89c5e700f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 406 additions and 31 deletions

View file

@ -133,7 +133,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`.
Two more. Neither declares anything. 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. `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.

View file

@ -1710,7 +1710,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`. `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: Behavior changes:
@ -2840,6 +2840,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. 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.
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) ### 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. 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.

View file

@ -15,7 +15,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
async def on_log(params: LoggingMessageNotificationParams) -> None: async def on_log(params: LoggingMessageNotificationParams) -> None:
logs.append(params) logs.append(params)
async with Client(target, mode=mode, logging_callback=on_log) as client: # `log_level` is the 2026-07-28 per-request opt-in: without it a modern server
# sends no log notifications at all (pre-2026 servers ignore it and send anyway).
async with Client(target, mode=mode, logging_callback=on_log, log_level="info") as client:
# ── progress + logging: a short countdown delivers exactly `steps` of each, in order ── # ── progress + logging: a short countdown delivers exactly `steps` of each, in order ──
updates: list[tuple[float, float | None, str | None]] = [] updates: list[tuple[float, float | None, str | None]] = []

View file

@ -16,9 +16,10 @@ def build_server() -> MCPServer:
try: try:
for i in range(1, steps + 1): for i in range(1, steps + 1):
await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}") await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}")
# No non-deprecated logging helper on Context yet, so send the raw # Protocol logging is deprecated (SEP-2577), so the raw notification
# notification. `related_request_id` keeps it on this request's response # keeps this warning-free. On 2026-07-28+ the client only receives it
# stream (matters over streamable HTTP). # because it opts in with `log_level=`; `related_request_id` keeps it on
# this request's response stream (matters over streamable HTTP).
await ctx.request_context.session.send_notification( await ctx.request_context.session.send_notification(
types.LoggingMessageNotification( types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams( params=types.LoggingMessageNotificationParams(

View file

@ -312,6 +312,16 @@ class Client:
logging_callback: LoggingFnT | None = None logging_callback: LoggingFnT | None = None
"""Callback for handling logging notifications.""" """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"? # TODO(Marcelo): Why do we have both "callback" and "handler"?
message_handler: MessageHandlerFnT | None = None message_handler: MessageHandlerFnT | None = None
"""Callback for handling raw messages.""" """Callback for handling raw messages."""
@ -425,6 +435,7 @@ class Client:
sampling_capabilities=self.sampling_capabilities, sampling_capabilities=self.sampling_capabilities,
list_roots_callback=self.list_roots_callback, list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback, logging_callback=self.logging_callback,
log_level=self.log_level,
message_handler=message_handler, message_handler=message_handler,
client_info=self.client_info, client_info=self.client_info,
elicitation_callback=self.elicitation_callback, elicitation_callback=self.elicitation_callback,

View file

@ -19,6 +19,7 @@ from mcp_types import (
CLIENT_INFO_META_KEY, CLIENT_INFO_META_KEY,
CONNECTION_CLOSED, CONNECTION_CLOSED,
INTERNAL_ERROR, INTERNAL_ERROR,
LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY, PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY, SERVER_INFO_META_KEY,
@ -121,6 +122,8 @@ def _make_modern_stamp(
client_info: dict[str, Any], client_info: dict[str, Any],
capabilities: dict[str, Any], capabilities: dict[str, Any],
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]], resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
*,
log_level: types.LoggingLevel | None = None,
) -> Callable[[dict[str, Any], CallOptions], None]: ) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None: def stamp(data: dict[str, Any], opts: CallOptions) -> None:
params = data.setdefault("params", {}) params = data.setdefault("params", {})
@ -128,6 +131,11 @@ def _make_modern_stamp(
meta[PROTOCOL_VERSION_META_KEY] = protocol_version meta[PROTOCOL_VERSION_META_KEY] = protocol_version
meta[CLIENT_INFO_META_KEY] = client_info meta[CLIENT_INFO_META_KEY] = client_info
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities 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 # `cancel_on_abandon` stays at the dispatcher default (True): the
# courtesy `notifications/cancelled` is the abandon signal. On the # courtesy `notifications/cancelled` is the abandon signal. On the
# stream transports it is the 2026 wire's cancellation spelling; the # stream transports it is the 2026 wire's cancellation spelling; the
@ -372,6 +380,7 @@ class ClientSession:
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None, client_info: types.Implementation | None = None,
*, *,
log_level: types.LoggingLevel | None = None,
sampling_capabilities: types.SamplingCapability | None = None, sampling_capabilities: types.SamplingCapability | None = None,
extensions: dict[str, dict[str, Any]] | None = None, extensions: dict[str, dict[str, Any]] | None = None,
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None, result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
@ -393,6 +402,7 @@ class ClientSession:
self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback self._logging_callback = logging_callback or _default_logging_callback
self._log_level: types.LoggingLevel | None = log_level
self._message_handler = message_handler or _default_message_handler self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by # Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
@ -645,7 +655,9 @@ class ClientSession:
version = mutual[-1] version = mutual[-1]
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) 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) 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_result = result
self._discover_server_info = _parse_server_info_stamp(result) self._discover_server_info = _parse_server_info_stamp(result)
self._initialize_result = None self._initialize_result = None

View file

@ -22,10 +22,11 @@ from __future__ import annotations
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from typing import Any, TypeVar, overload from typing import Any, Final, TypeVar, get_args, overload
import anyio import anyio
from mcp_types import ( from mcp_types import (
LOG_LEVEL_META_KEY,
ClientCapabilities, ClientCapabilities,
CreateMessageRequest, CreateMessageRequest,
CreateMessageResult, CreateMessageResult,
@ -41,7 +42,7 @@ from mcp_types import (
Request, Request,
) )
from mcp_types import methods as _methods 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 pydantic import BaseModel, ValidationError
from typing_extensions import deprecated from typing_extensions import deprecated
@ -52,6 +53,40 @@ from mcp.shared.peer import Meta, dump_params
__all__ = ["Connection"] __all__ = ["Connection"]
logger = logging.getLogger(__name__) 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) :])
ResultT = TypeVar("ResultT", bound=BaseModel) ResultT = TypeVar("ResultT", bound=BaseModel)
@ -373,7 +408,17 @@ class Connection:
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) @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: 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} params: dict[str, Any] = {"level": level, "data": data}
if logger is not None: if logger is not None:
params["logger"] = logger params["logger"] = logger

View file

@ -1,3 +1,4 @@
import logging
from collections.abc import Awaitable, Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Generic, Protocol from typing import Any, Generic, Protocol
@ -6,7 +7,7 @@ from mcp_types import LoggingLevel, RequestId, RequestParamsMeta
from pydantic import BaseModel from pydantic import BaseModel
from typing_extensions import TypeVar, deprecated 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.server.session import ServerSession
from mcp.shared.context import BaseContext from mcp.shared.context import BaseContext
from mcp.shared.dispatcher import DispatchContext from mcp.shared.dispatcher import DispatchContext
@ -15,6 +16,12 @@ from mcp.shared.message import CloseSSEStreamCallback
from mcp.shared.peer import Meta from mcp.shared.peer import Meta
from mcp.shared.transport_context import TransportContext 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. # Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan.
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any]) LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
RequestT = TypeVar("RequestT", default=Any) RequestT = TypeVar("RequestT", default=Any)
@ -67,6 +74,9 @@ class Context(BaseContext[TransportContext], Generic[LifespanT_co]):
super().__init__(dctx, meta=meta) super().__init__(dctx, meta=meta)
self._lifespan = lifespan self._lifespan = lifespan
self._connection = connection 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 @property
def lifespan(self) -> LifespanT_co: def lifespan(self) -> LifespanT_co:
@ -102,7 +112,15 @@ class Context(BaseContext[TransportContext], Generic[LifespanT_co]):
Uses this request's back-channel (so the entry rides the request's SSE Uses this request's back-channel (so the entry rides the request's SSE
stream in streamable HTTP), not the standalone stream - use stream in streamable HTTP), not the standalone stream - use
`ctx.connection.log(...)` for that. `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} params: dict[str, Any] = {"level": level, "data": data}
if logger is not None: if logger is not None:
params["logger"] = logger params["logger"] = logger

View file

@ -321,7 +321,10 @@ class ServerRunner(Generic[LifespanT]):
# Per-request session: `dctx` is the request-scoped channel (auto-threads # Per-request session: `dctx` is the request-scoped channel (auto-threads
# its own request_id on streamable HTTP); the standalone channel is read # its own request_id on streamable HTTP); the standalone channel is read
# off `connection.outbound`. `related_request_id` on the public API selects. # off `connection.outbound`. `related_request_id` on the public API selects.
session = ServerSession(dctx, self.connection) # `meta` carries a request's log-level opt-in for the session's log gate. A
# notification has no request to opt in (and no response stream to carry
# the log entry), so its `_meta` never opens the gate.
session = ServerSession(dctx, self.connection, request_meta=meta if dctx.request_id is not None else None)
return ServerRequestContext( return ServerRequestContext(
session=session, session=session,
lifespan_context=self.lifespan_state, lifespan_context=self.lifespan_state,

View file

@ -6,14 +6,16 @@ Handlers reach it as `ctx.session` and use the typed helpers (`elicit_form`,
`send_log_message`, ...) to call back to the client. `send_log_message`, ...) to call back to the client.
""" """
import logging
from typing import Any, TypeVar, overload from typing import Any, TypeVar, overload
import mcp_types as types import mcp_types as types
from mcp_types import methods as _methods from mcp_types import methods as _methods
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl, BaseModel from pydantic import AnyUrl, BaseModel
from typing_extensions import deprecated from typing_extensions import deprecated
from mcp.server.connection import Connection from mcp.server.connection import Connection, allowed_log_levels
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools
from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.exceptions import MCPDeprecationWarning
@ -21,6 +23,12 @@ from mcp.shared.message import ServerMessageMetadata
__all__ = ["ServerSession"] __all__ = ["ServerSession"]
logger = logging.getLogger(__name__)
# `send_log_message`'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
ResultT = TypeVar("ResultT", bound=BaseModel) ResultT = TypeVar("ResultT", bound=BaseModel)
@ -36,9 +44,22 @@ class ServerSession:
never crosses the `Outbound` Protocol. never crosses the `Outbound` Protocol.
""" """
def __init__(self, request_outbound: DispatchContext[Any], connection: Connection) -> None: def __init__(
self,
request_outbound: DispatchContext[Any],
connection: Connection,
*,
request_meta: types.RequestParamsMeta | None = None,
) -> None:
self._request_outbound = request_outbound self._request_outbound = request_outbound
self._connection = connection self._connection = connection
# The per-request log-delivery contract, fixed at construction: on
# 2026-07-28+ the inbound request's `_meta` log-level opt-in decides
# which `notifications/message` levels may be sent for this request
# (and they ride this request's stream only); on handshake versions
# every level may be sent (`logging/setLevel`-era semantics).
self._log_is_request_scoped = connection.protocol_version in MODERN_PROTOCOL_VERSIONS
self._allowed_log_levels = allowed_log_levels(connection.protocol_version, request_meta)
@property @property
def client_params(self) -> types.InitializeRequestParams | None: def client_params(self) -> types.InitializeRequestParams | None:
@ -106,7 +127,10 @@ class ServerSession:
related_request_id: types.RequestId | None = None, related_request_id: types.RequestId | None = None,
) -> None: ) -> None:
"""Send a typed server-to-client notification.""" """Send a typed server-to-client notification."""
channel = self._request_outbound if related_request_id is not None else self._connection.outbound await self._notify(notification, request_scoped=related_request_id is not None)
async def _notify(self, notification: types.ServerNotification, *, request_scoped: bool) -> None:
channel = self._request_outbound if request_scoped else self._connection.outbound
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await channel.notify(data["method"], data.get("params")) await channel.notify(data["method"], data.get("params"))
@ -122,8 +146,20 @@ class ServerSession:
logger: str | None = None, logger: str | None = None,
related_request_id: types.RequestId | None = None, related_request_id: types.RequestId | None = None,
) -> None: ) -> None:
"""Send a log message notification.""" """Send a log message notification.
await self.send_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.
"""
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
await self._notify(
types.LoggingMessageNotification( types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams( params=types.LoggingMessageNotificationParams(
level=level, level=level,
@ -131,7 +167,7 @@ class ServerSession:
logger=logger, logger=logger,
), ),
), ),
related_request_id, request_scoped=self._log_is_request_scoped or related_request_id is not None,
) )
async def send_resource_updated(self, uri: str | AnyUrl) -> None: async def send_resource_updated(self, uri: str | AnyUrl) -> None:

View file

@ -13,6 +13,7 @@ from mcp_types import (
CONNECTION_CLOSED, CONNECTION_CLOSED,
INTERNAL_ERROR, INTERNAL_ERROR,
INVALID_PARAMS, INVALID_PARAMS,
LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY, PROTOCOL_VERSION_META_KEY,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
@ -1548,6 +1549,21 @@ async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp
assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28" assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28"
@pytest.mark.anyio
async def test_log_level_opt_in_is_stamped_on_modern_requests_and_overridable_per_call() -> None:
"""SDK-defined: `log_level` stamps the reserved log-level `_meta` key on every modern
request, and a request supplying that key in its own `_meta` overrides the default."""
dispatcher = _ScriptedDispatcher(_discover_result_dict(), {}, {})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher, log_level="warning") as session:
await session.discover()
await session.send_ping()
await session.send_ping(meta={LOG_LEVEL_META_KEY: "debug"})
default_meta, override_meta = (params["_meta"] for _, params in dispatcher.calls[-2:] if params is not None)
assert default_meta[LOG_LEVEL_META_KEY] == "warning"
assert override_meta[LOG_LEVEL_META_KEY] == "debug"
@pytest.mark.anyio @pytest.mark.anyio
async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None: async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None:
"""Spec SHOULD: a -32022 reply that names a mutually-supported version """Spec SHOULD: a -32022 reply that names a mutually-supported version

View file

@ -21,6 +21,7 @@ from mcp_types import (
JSONRPCMessage, JSONRPCMessage,
JSONRPCRequest, JSONRPCRequest,
JSONRPCResponse, JSONRPCResponse,
LoggingLevel,
jsonrpc_message_adapter, jsonrpc_message_adapter,
) )
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
@ -68,6 +69,7 @@ class Connect(Protocol):
sampling_callback: SamplingFnT | None = None, sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None, list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None, logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None, client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None, elicitation_callback: ElicitationFnT | None = None,
@ -84,6 +86,7 @@ async def connect_in_memory(
sampling_callback: SamplingFnT | None = None, sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None, list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None, logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None, client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None, elicitation_callback: ElicitationFnT | None = None,
@ -103,6 +106,7 @@ async def connect_in_memory(
sampling_callback=sampling_callback, sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback, list_roots_callback=list_roots_callback,
logging_callback=logging_callback, logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler, message_handler=message_handler,
client_info=client_info, client_info=client_info,
elicitation_callback=elicitation_callback, elicitation_callback=elicitation_callback,
@ -123,6 +127,7 @@ async def connect_over_streamable_http(
sampling_callback: SamplingFnT | None = None, sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None, list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None, logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None, client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None, elicitation_callback: ElicitationFnT | None = None,
@ -158,6 +163,7 @@ async def connect_over_streamable_http(
sampling_callback=sampling_callback, sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback, list_roots_callback=list_roots_callback,
logging_callback=logging_callback, logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler, message_handler=message_handler,
client_info=client_info, client_info=client_info,
elicitation_callback=elicitation_callback, elicitation_callback=elicitation_callback,
@ -232,6 +238,7 @@ async def client_via_http(
http_client: httpx2.AsyncClient, http_client: httpx2.AsyncClient,
*, *,
logging_callback: LoggingFnT | None = None, logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
elicitation_callback: ElicitationFnT | None = None, elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]: ) -> AsyncIterator[Client]:
@ -248,6 +255,7 @@ async def client_via_http(
# closing DELETE); the modern flow is sessionless and would silently change the subject. # closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy", mode="legacy",
logging_callback=logging_callback, logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler, message_handler=message_handler,
elicitation_callback=elicitation_callback, elicitation_callback=elicitation_callback,
) as client: ) as client:
@ -360,6 +368,7 @@ async def connect_over_sse(
sampling_callback: SamplingFnT | None = None, sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None, list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None, logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None, client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None, elicitation_callback: ElicitationFnT | None = None,
@ -394,6 +403,7 @@ async def connect_over_sse(
sampling_callback=sampling_callback, sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback, list_roots_callback=list_roots_callback,
logging_callback=logging_callback, logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler, message_handler=message_handler,
client_info=client_info, client_info=client_info,
elicitation_callback=elicitation_callback, elicitation_callback=elicitation_callback,

View file

@ -1522,6 +1522,7 @@ REQUIREMENTS: dict[str, Requirement] = {
), ),
), ),
removed_in="2026-07-28", removed_in="2026-07-28",
superseded_by="logging:per-request:threshold",
note=( note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta." "io.modelcontextprotocol/logLevel in _meta."
@ -1531,6 +1532,7 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
behavior="logging/setLevel delivers the requested level to the server's handler and returns an empty result.", behavior="logging/setLevel delivers the requested level to the server's handler and returns an empty result.",
removed_in="2026-07-28", removed_in="2026-07-28",
superseded_by="logging:per-request:opt-in",
note=( note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta." "io.modelcontextprotocol/logLevel in _meta."
@ -1540,11 +1542,40 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/server/utilities/logging#error-handling", source=f"{SPEC_BASE_URL}/server/utilities/logging#error-handling",
behavior="logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).", behavior="logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).",
removed_in="2026-07-28", removed_in="2026-07-28",
superseded_by="logging:per-request:invalid-level",
note=( note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta." "io.modelcontextprotocol/logLevel in _meta."
), ),
), ),
"logging:per-request:opt-in": Requirement(
source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level",
behavior=(
"The server does not send log message notifications for a request unless the request opts in by "
"carrying io.modelcontextprotocol/logLevel in _meta; a handler's log calls on an un-opted "
"request are dropped, not delivered on another stream."
),
added_in="2026-07-28",
supersedes=("logging:set-level",),
),
"logging:per-request:threshold": Requirement(
source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level",
behavior=(
"A request that opts in receives log message notifications only at or above the level named in "
"its io.modelcontextprotocol/logLevel; entries below the level are dropped."
),
added_in="2026-07-28",
supersedes=("logging:message:filtered",),
),
"logging:per-request:invalid-level": Requirement(
source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#error-handling",
behavior=(
"A request whose io.modelcontextprotocol/logLevel is not a recognized log level is rejected with "
"JSON-RPC error -32602 (Invalid params)."
),
added_in="2026-07-28",
supersedes=("logging:set-level:invalid-level",),
),
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Sampling (server → client) # Sampling (server → client)
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════

View file

@ -8,8 +8,16 @@ streamable HTTP, so plain-list collection is deterministic on every transport le
import mcp_types as types import mcp_types as types
import pytest import pytest
from inline_snapshot import snapshot from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent from mcp_types import (
INVALID_PARAMS,
LOG_LEVEL_META_KEY,
CallToolResult,
EmptyResult,
LoggingMessageNotificationParams,
TextContent,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp from tests._stamp import Unstamp
from tests.interaction._connect import Connect from tests.interaction._connect import Connect
@ -81,7 +89,7 @@ async def test_log_messages_reach_logging_callback_in_order(connect: Connect, un
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level "logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
) )
async with connect(server, logging_callback=collect) as client: async with connect(server, logging_callback=collect, log_level="debug") as client:
result = await client.call_tool("chatty", {}) result = await client.call_tool("chatty", {})
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")])) assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")]))
@ -122,7 +130,63 @@ async def test_log_messages_at_every_severity_level(connect: Connect) -> None:
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level "logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
) )
async with connect(server, logging_callback=collect) as client: async with connect(server, logging_callback=collect, log_level="debug") as client:
await client.call_tool("siren", {}) await client.call_tool("siren", {})
assert [params.level for params in received] == list(ALL_LEVELS) assert [params.level for params in received] == list(ALL_LEVELS)
def _siren_server() -> Server:
"""A server whose `siren` tool logs one message at each of the eight severity levels.
The messages are sent without `related_request_id`: on 2026-07-28+ log delivery is
request-scoped by construction, so they still ride the requesting stream on every leg.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="siren", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "siren"
for level in ALL_LEVELS:
await ctx.session.send_log_message(level=level, data=f"a {level} message") # pyright: ignore[reportDeprecated]
return CallToolResult(content=[TextContent(text="logged")])
return Server("logger", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("logging:per-request:opt-in")
@requirement("logging:per-request:threshold")
async def test_log_delivery_follows_the_per_request_log_level(connect: Connect) -> None:
"""Without io.modelcontextprotocol/logLevel in _meta a request gets no log messages;
with it, only entries at or above the requested level are delivered, in order.
The handler emits at every severity in both phases: the un-opted request receives
nothing (the log calls are dropped, not delivered on some other stream), and the request
opting in at `warning` receives warning and above.
"""
received: list[types.LoggingLevel] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params.level)
async with connect(_siren_server(), logging_callback=collect) as client:
result = await client.call_tool("siren", {})
assert isinstance(result.content[0], TextContent) and result.content[0].text == "logged"
assert received == []
async with connect(_siren_server(), logging_callback=collect, log_level="warning") as client:
await client.call_tool("siren", {})
assert received == ["warning", "error", "critical", "alert", "emergency"]
@requirement("logging:per-request:invalid-level")
async def test_a_request_with_an_unrecognized_log_level_is_rejected(connect: Connect) -> None:
"""A request whose _meta names an unrecognized log level is rejected with -32602 before the handler runs."""
async with connect(_siren_server()) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("siren", {}, meta={LOG_LEVEL_META_KEY: "verbose"})
assert exc_info.value.error.code == INVALID_PARAMS

View file

@ -50,7 +50,7 @@ async def test_context_logging_helpers_send_log_notifications(connect: Connect)
async def collect(params: LoggingMessageNotificationParams) -> None: async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params) received.append(params)
async with connect(mcp, logging_callback=collect) as client: async with connect(mcp, logging_callback=collect, log_level="debug") as client:
result = await client.call_tool("narrate", {}) result = await client.call_tool("narrate", {})
advertised_logging = client.server_capabilities.logging advertised_logging = client.server_capabilities.logging
@ -142,7 +142,7 @@ async def test_report_progress_without_a_progress_token_sends_nothing(connect: C
async def collect(message: IncomingMessage) -> None: async def collect(message: IncomingMessage) -> None:
received.append(message) received.append(message)
async with connect(mcp, message_handler=collect) as client: async with connect(mcp, message_handler=collect, log_level="debug") as client:
result = await client.call_tool("mill", {}) result = await client.call_tool("mill", {})
assert unstamped(result) == snapshot( assert unstamped(result) == snapshot(

View file

@ -431,7 +431,7 @@ async def test_adding_and_removing_tools_does_not_notify_connected_clients(conne
async def collect(message: IncomingMessage) -> None: async def collect(message: IncomingMessage) -> None:
received.append(message) received.append(message)
async with connect(mcp, message_handler=collect) as client: async with connect(mcp, message_handler=collect, log_level="debug") as client:
before = await client.list_tools() before = await client.list_tools()
await client.call_tool("grow", {}) await client.call_tool("grow", {})
after = await client.list_tools() after = await client.list_tools()

View file

@ -283,6 +283,16 @@ async def test_connection_log_sends_logging_message_notification():
assert params["logger"] == "my.logger" assert params["logger"] == "my.logger"
@pytest.mark.anyio
async def test_connection_log_sends_nothing_on_a_modern_connection():
"""2026 log delivery is a per-request opt-in on the requesting stream; the
connection-scoped standalone entry has no request to opt in, so it never sends."""
out = StubOutbound()
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=out)
await conn.log("emergency", "unheard") # pyright: ignore[reportDeprecated]
assert out.notifications == []
@pytest.mark.anyio @pytest.mark.anyio
async def test_connection_log_with_meta_includes_meta_in_params(): async def test_connection_log_with_meta_includes_meta_in_params():
out = StubOutbound() out = StubOutbound()

View file

@ -25,6 +25,7 @@ from mcp_types import (
INVALID_PARAMS, INVALID_PARAMS,
INVALID_REQUEST, INVALID_REQUEST,
LATEST_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION,
LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY, PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY, SERVER_INFO_META_KEY,
@ -1759,6 +1760,26 @@ async def test_dual_era_loop_legacy_notifications_reach_the_loop_runner(server:
await handled.wait() await handled.wait()
@pytest.mark.anyio
async def test_dual_era_loop_a_notifications_meta_never_opens_the_log_gate(server: SrvT):
"""The 2026 log-delivery opt-in is a request's `_meta`: an inbound notification
has no request to opt in, so a handler logging in response to one - even
with the log-level key in the notification's own `_meta` - sends nothing."""
logged = anyio.Event()
async def log_it(ctx: Ctx, params: NotificationParams | None) -> None:
await ctx.session.send_log_message("emergency", "no request opted in") # pyright: ignore[reportDeprecated]
logged.set()
server.add_notification_handler("notifications/custom", NotificationParams, log_it)
async with dual_era_client(server) as (client, recorder):
await client.send_raw_request("tools/list", _modern_params())
meta = {**_modern_envelope(), LOG_LEVEL_META_KEY: "debug"}
await client.notify("notifications/custom", {"_meta": meta})
await logged.wait()
assert [method for method, _ in recorder.notifications] == []
@pytest.mark.anyio @pytest.mark.anyio
async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT): async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT):
"""A modern handler's standalone notification reaches the client over the """A modern handler's standalone notification reaches the client over the

View file

@ -11,6 +11,8 @@ from typing import Any
import anyio import anyio
import pytest import pytest
from mcp_types import LOG_LEVEL_META_KEY
from mcp_types.version import LATEST_MODERN_VERSION
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.context import Context from mcp.server.context import Context
@ -73,6 +75,34 @@ async def test_context_log_sends_request_scoped_message_notification():
assert params is not None and params["level"] == "debug" and params["data"] == "hello" assert params is not None and params["level"] == "debug" and params["data"] == "hello"
@pytest.mark.anyio
async def test_context_log_is_gated_by_the_request_log_level_at_2026():
"""On a 2026 connection an un-opted request delivers nothing; opting in at
`warning` delivers `warning`+ and drops what falls below."""
crec = Recorder()
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
modern = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=dctx)
silent: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=modern)
await silent.log("emergency", "dropped: no opt-in") # pyright: ignore[reportDeprecated]
opted: Context[_Lifespan] = Context(
dctx, lifespan=_Lifespan("app"), connection=modern, meta={LOG_LEVEL_META_KEY: "warning"}
)
await opted.log("info", "dropped: below level") # pyright: ignore[reportDeprecated]
await opted.log("warning", "delivered") # pyright: ignore[reportDeprecated]
return {}
async with running_pair(direct_pair, server_on_request=server_on_request, client_on_notify=c_notify) as (
client,
*_,
):
with anyio.fail_after(5):
await client.send_raw_request("t", None)
await crec.notified.wait()
assert [p["data"] for _, p in crec.notifications if p is not None] == ["delivered"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_context_log_includes_logger_and_meta_when_supplied(): async def test_context_log_includes_logger_and_meta_when_supplied():
crec = Recorder() crec = Recorder()

View file

@ -12,6 +12,7 @@ from typing import Any
import mcp_types as types import mcp_types as types
import pytest import pytest
from mcp_types import ( from mcp_types import (
LOG_LEVEL_META_KEY,
ClientCapabilities, ClientCapabilities,
Implementation, Implementation,
SamplingCapability, SamplingCapability,
@ -157,6 +158,54 @@ async def test_send_notification_routes_by_related_request_id():
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"] assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
def _modern_session(
request_ch: StubOutbound, standalone_ch: StubOutbound, *, request_meta: types.RequestParamsMeta | None = None
) -> ServerSession:
"""A 2026-era session with distinct channels, carrying the inbound request's `_meta`."""
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=standalone_ch)
return ServerSession(request_ch, conn, request_meta=request_meta)
@pytest.mark.anyio
async def test_send_log_message_drops_everything_without_a_log_level_opt_in_at_2026():
"""No `_meta` log-level opt-in on a 2026 request means no `notifications/message` at all."""
request_ch, standalone_ch = StubOutbound(), StubOutbound()
session = _modern_session(request_ch, standalone_ch)
await session.send_log_message("emergency", "on fire", related_request_id="req-1") # pyright: ignore[reportDeprecated]
assert request_ch.notifications == [] and standalone_ch.notifications == []
@pytest.mark.anyio
async def test_send_log_message_drops_levels_below_the_requested_one_at_2026():
request_ch, standalone_ch = StubOutbound(), StubOutbound()
session = _modern_session(request_ch, standalone_ch, request_meta={LOG_LEVEL_META_KEY: "warning"})
await session.send_log_message("info", "quiet") # pyright: ignore[reportDeprecated]
await session.send_log_message("error", "loud") # pyright: ignore[reportDeprecated]
assert [p["level"] for _, p in request_ch.notifications if p is not None] == ["error"]
@pytest.mark.anyio
async def test_send_log_message_is_request_scoped_at_2026_even_without_related_request_id():
"""The spec forbids 2026 log delivery on any stream but the requesting one, so
`related_request_id` no longer selects the standalone channel there."""
request_ch, standalone_ch = StubOutbound(), StubOutbound()
session = _modern_session(request_ch, standalone_ch, request_meta={LOG_LEVEL_META_KEY: "debug"})
await session.send_log_message("info", "hello") # pyright: ignore[reportDeprecated]
assert [m for m, _ in request_ch.notifications] == ["notifications/message"]
assert standalone_ch.notifications == []
@pytest.mark.anyio
async def test_send_log_message_on_a_handshake_version_still_routes_by_related_request_id():
"""Handshake versions keep the pre-2026 semantics: every level sends, channel by `related_request_id`."""
request_ch, standalone_ch = StubOutbound(), StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
await session.send_log_message("debug", "loose") # pyright: ignore[reportDeprecated]
await session.send_log_message("debug", "tied", related_request_id="req-1") # pyright: ignore[reportDeprecated]
assert [m for m, _ in standalone_ch.notifications] == ["notifications/message"]
assert [m for m, _ in request_ch.notifications] == ["notifications/message"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_report_progress_delegates_to_the_request_dispatch_context(): async def test_report_progress_delegates_to_the_request_dispatch_context():
"""`report_progress` calls the per-request `DispatchContext.progress` seam, never the """`report_progress` calls the per-request `DispatchContext.progress` seam, never the

Some files were not shown because too many files have changed in this diff Show more