Gate log notifications on the per-request log-level opt-in at 2026-07-28 (#3198)
This commit is contained in:
parent
b61ce388dd
commit
89c5e700f2
6 changed files with 406 additions and 31 deletions
|
|
@ -312,6 +312,16 @@ class Client:
|
|||
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."""
|
||||
|
|
@ -425,6 +435,7 @@ class Client:
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from mcp_types import (
|
|||
CLIENT_INFO_META_KEY,
|
||||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
LOG_LEVEL_META_KEY,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SERVER_INFO_META_KEY,
|
||||
|
|
@ -121,6 +122,8 @@ def _make_modern_stamp(
|
|||
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", {})
|
||||
|
|
@ -128,6 +131,11 @@ def _make_modern_stamp(
|
|||
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
|
||||
# courtesy `notifications/cancelled` is the abandon signal. On the
|
||||
# stream transports it is the 2026 wire's cancellation spelling; the
|
||||
|
|
@ -372,6 +380,7 @@ class ClientSession:
|
|||
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,
|
||||
|
|
@ -393,6 +402,7 @@ class ClientSession:
|
|||
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
|
||||
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
|
||||
|
|
@ -645,7 +655,9 @@ class ClientSession:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ from __future__ import annotations
|
|||
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,
|
||||
|
|
@ -41,7 +42,7 @@ from mcp_types import (
|
|||
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
|
||||
|
||||
|
|
@ -52,6 +53,40 @@ from mcp.shared.peer import Meta, dump_params
|
|||
__all__ = ["Connection"]
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -373,7 +408,17 @@ class Connection:
|
|||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, Protocol
|
||||
|
|
@ -6,7 +7,7 @@ from mcp_types import LoggingLevel, RequestId, RequestParamsMeta
|
|||
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
|
||||
|
|
@ -15,6 +16,12 @@ from mcp.shared.message import CloseSSEStreamCallback
|
|||
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)
|
||||
|
|
@ -67,6 +74,9 @@ class Context(BaseContext[TransportContext], Generic[LifespanT_co]):
|
|||
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:
|
||||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -321,7 +321,10 @@ class ServerRunner(Generic[LifespanT]):
|
|||
# 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 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(
|
||||
session=session,
|
||||
lifespan_context=self.lifespan_state,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
import mcp_types as types
|
||||
from mcp_types import methods as _methods
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
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.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning
|
||||
|
|
@ -21,6 +23,12 @@ from mcp.shared.message import ServerMessageMetadata
|
|||
|
||||
__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)
|
||||
|
||||
|
||||
|
|
@ -36,9 +44,22 @@ class ServerSession:
|
|||
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._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
|
||||
def client_params(self) -> types.InitializeRequestParams | None:
|
||||
|
|
@ -106,7 +127,10 @@ class ServerSession:
|
|||
related_request_id: types.RequestId | None = None,
|
||||
) -> None:
|
||||
"""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)
|
||||
await channel.notify(data["method"], data.get("params"))
|
||||
|
||||
|
|
@ -122,8 +146,20 @@ class ServerSession:
|
|||
logger: str | None = None,
|
||||
related_request_id: types.RequestId | None = None,
|
||||
) -> None:
|
||||
"""Send a log message notification."""
|
||||
await self.send_notification(
|
||||
"""Send a log message notification.
|
||||
|
||||
On 2026-07-28+ delivery is a per-request opt-in: nothing is sent
|
||||
unless this request's `_meta` carried the reserved log-level key, and
|
||||
entries below the requested level are dropped (debug-logged). What is
|
||||
sent rides this request's stream regardless of `related_request_id` -
|
||||
the spec forbids `notifications/message` on any stream but the one
|
||||
carrying the response. Handshake versions send unconditionally on the
|
||||
channel `related_request_id` selects, as before.
|
||||
"""
|
||||
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(
|
||||
params=types.LoggingMessageNotificationParams(
|
||||
level=level,
|
||||
|
|
@ -131,7 +167,7 @@ class ServerSession:
|
|||
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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue