Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 3 additions & 1 deletion examples/stories/streaming/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
async def on_log(params: LoggingMessageNotificationParams) -> None:
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 ──
updates: list[tuple[float, float | None, str | None]] = []

Expand Down
7 changes: 4 additions & 3 deletions examples/stories/streaming/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ async def countdown(steps: int, ctx: Context) -> dict[str, int]:
try:
for i in range(1, steps + 1):
await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}")
# No non-deprecated logging helper on Context yet, so send the raw
# notification. `related_request_id` keeps it on this request's response
# stream (matters over streamable HTTP).
# Protocol logging is deprecated (SEP-2577), so the raw notification
# keeps this warning-free. On 2026-07-28+ the client only receives it
# because it opts in with `log_level=`; `related_request_id` keeps it on
# this request's response stream (matters over streamable HTTP).
Comment on lines +19 to +22

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.

🟡 The rewritten comment claims the client "only receives it because it opts in with log_level=", but this raw ctx.request_context.session.send_notification(LoggingMessageNotification(...)) path bypasses the PR's gate entirely — ServerSession.send_notification_notify has no _allowed_log_levels check (the gate lives only in send_log_message / Context.log / Connection.log), so on a 2026 connection the notification is delivered whether or not the request opted in, and the client's new log_level="info" is decorative for this story. Either route through the gated send_log_message or fix the comment to say the raw send bypasses the opt-in; relatedly, examples/stories/streaming/README.md still quotes the pre-PR Client(target, mode=mode, logging_callback=on_log) construction with no log_level=.

Extended reasoning...

The bug. This PR rewrites the comment above the raw log send to read: "On 2026-07-28+ the client only receives it because it opts in with log_level=". That causal claim is false for this code path. The example emits the log entry via ctx.request_context.session.send_notification(types.LoggingMessageNotification(...), related_request_id=...) — the raw, un-gated notification path — so the client receives it regardless of whether the request carried the io.modelcontextprotocol/logLevel opt-in. The new log_level="info" added to examples/stories/streaming/client.py in this same PR does not influence delivery for this story at all.

The code path. ServerSession.send_notification delegates to _notify (src/mcp/server/session.py:127-135), which dumps the model and calls channel.notify directly — there is no _allowed_log_levels check anywhere on that path. The per-request gate this PR introduces lives exclusively in three places: ServerSession.send_log_message, Context.log, and Connection.log (each checks level not in allowed_log_levels(...) before sending). A grep over src/mcp confirms nothing downstream filters notifications/message either: neither the dispatchers nor Connection.notify inspect the method. On the client side, notifications/message is a defined server notification at 2026-07-28, so ClientSession._on_notify parses it and invokes logging_callback unconditionally — the client never enforces its own opt-in.

Step-by-step proof. (1) Remove log_level="info" from the streaming story's Client(...) call and run the story on a 2026 connection. (2) The countdown tool calls ctx.request_context.session.send_notification(LoggingMessageNotification(...), related_request_id=...). (3) send_notification calls _notify(notification, request_scoped=True), which goes straight to channel.notify("notifications/message", params) — no gate consulted, because _allowed_log_levels is only read inside send_log_message. (4) The client's _on_notify parses the notification and awaits on_log, so logs fills exactly as before. The story's assertions pass with or without the opt-in — proving the comment's "only receives it because it opts in" is backwards. (Contrast: change the tool to await ctx.session.send_log_message("info", ...) and the un-opted run delivers nothing, which is the behavior the comment describes.)

Why it matters. The example teaches the opposite of how the mechanism works. A reader copying this pattern believing the SDK gates the raw path will ship a 2026 server that sends notifications/message for requests that never opted in — violating the spec's MUST NOT ("the server MUST NOT emit notifications/message for a request whose _meta lacks io.modelcontextprotocol/logLevel"). server_lowlevel.py in the same story uses the identical ungated raw send. As shipped the story happens to be spec-compliant on the wire only because the client does opt in at "info" and the messages are "info"-level — but that compliance is supplied by the client, not enforced by the server as the comment implies.

Related staleness. examples/stories/streaming/README.md still quotes the pre-PR construction Client(target, mode=mode, logging_callback=on_log) with no log_level=, which no longer matches client.py, and its "What to look at" prose describing the raw-send rationale predates the gate.

How to fix. Either (a) switch the story's servers to the gated helper — await ctx.session.send_log_message("info", f"step {i}/{steps}", logger="countdown") with the deprecation pragma, as this PR's own interaction tests do — so the comment becomes true and the example demonstrates the opt-in end to end; or (b) keep the raw send and reword the comment to say the raw notification path bypasses the per-request gate (the server must honor the opt-in itself when using it). Update the streaming README's client snippet to include log_level="info" in the same pass. Severity is nit: the story runs and asserts correctly either way, so nothing breaks at merge — the harm is a misleading comment/README introduced by this PR's own rewrite.

await ctx.request_context.session.send_notification(
types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams(
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,13 +122,20 @@ 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", {})
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
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
Expand Down Expand Up @@ -372,6 +380,7 @@ def __init__(
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 @@ def __init__(
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 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
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
51 changes: 48 additions & 3 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,7 +42,7 @@
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

Expand All @@ -52,6 +53,40 @@
__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) :])

Comment thread
maxisbey marked this conversation as resolved.

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

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

@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
Loading
Loading