|
22 | 22 | import logging |
23 | 23 | from collections.abc import Mapping |
24 | 24 | from contextlib import AsyncExitStack |
25 | | -from typing import Any, TypeVar, overload |
| 25 | +from typing import Any, Final, TypeVar, get_args, overload |
26 | 26 |
|
27 | 27 | import anyio |
28 | 28 | from mcp_types import ( |
| 29 | + LOG_LEVEL_META_KEY, |
29 | 30 | ClientCapabilities, |
30 | 31 | CreateMessageRequest, |
31 | 32 | CreateMessageResult, |
|
41 | 42 | Request, |
42 | 43 | ) |
43 | 44 | from mcp_types import methods as _methods |
44 | | -from mcp_types.version import LATEST_HANDSHAKE_VERSION |
| 45 | +from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS |
45 | 46 | from pydantic import BaseModel, ValidationError |
46 | 47 | from typing_extensions import deprecated |
47 | 48 |
|
48 | 49 | from mcp.shared.dispatcher import CallOptions, Outbound |
49 | 50 | from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError |
50 | 51 | from mcp.shared.peer import Meta, dump_params |
51 | 52 |
|
52 | | -__all__ = ["Connection"] |
| 53 | +__all__ = ["Connection", "allowed_log_levels"] |
53 | 54 |
|
54 | 55 | logger = logging.getLogger(__name__) |
| 56 | +# `Connection.log`'s `logger` parameter (public API, the spec's logger-name |
| 57 | +# field) shadows the module logger inside that method; this alias keeps the |
| 58 | +# module logger reachable there. |
| 59 | +_logger = logger |
| 60 | + |
| 61 | +_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel) |
| 62 | +"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the |
| 63 | +RFC 5424 scale) - the literal is the single source of the ordering.""" |
| 64 | + |
| 65 | +_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS) |
| 66 | + |
| 67 | + |
| 68 | +def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]: |
| 69 | + """The `notifications/message` levels deliverable for one inbound request. |
| 70 | +
|
| 71 | + 2026-07-28+ makes log delivery a per-request opt-in (server/utilities/ |
| 72 | + logging): the client sets the reserved `io.modelcontextprotocol/logLevel` |
| 73 | + `_meta` key, absent means no levels - the server MUST NOT send - and |
| 74 | + present means that level and above. An unrecognized value reads as absent; |
| 75 | + spec methods already reject a malformed value at surface validation |
| 76 | + before any handler runs, so that arm only serves custom methods, where |
| 77 | + dropping is the safe direction. Connection-scoped emitters pass |
| 78 | + `meta=None`: `logging/setLevel` is gone at 2026 and log delivery is |
| 79 | + request-scoped only, so they deliver nothing. Handshake versions keep |
| 80 | + their `logging/setLevel`-era semantics: every level may be sent, filtering |
| 81 | + is the application's `logging/setLevel` handler's job as before. |
| 82 | + """ |
| 83 | + if protocol_version not in MODERN_PROTOCOL_VERSIONS: |
| 84 | + return _ALL_LOG_LEVELS |
| 85 | + requested = (meta or {}).get(LOG_LEVEL_META_KEY) |
| 86 | + if requested not in _LOG_LEVELS: |
| 87 | + return frozenset() |
| 88 | + return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :]) |
| 89 | + |
55 | 90 |
|
56 | 91 | ResultT = TypeVar("ResultT", bound=BaseModel) |
57 | 92 |
|
@@ -373,7 +408,17 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non |
373 | 408 |
|
374 | 409 | @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) |
375 | 410 | async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None: |
376 | | - """Send a `notifications/message` log entry on the standalone stream. Best-effort.""" |
| 411 | + """Send a `notifications/message` log entry on the standalone stream. Best-effort. |
| 412 | +
|
| 413 | + On 2026-07-28+ connections this never sends: log delivery is a |
| 414 | + per-request opt-in that rides the requesting stream (`ctx.log`, |
| 415 | + `ctx.session.send_log_message`), and the standalone stream is |
| 416 | + forbidden from carrying `notifications/message`, so the entry is |
| 417 | + debug-logged and dropped. |
| 418 | + """ |
| 419 | + if level not in allowed_log_levels(self.protocol_version, None): |
| 420 | + _logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version) |
| 421 | + return |
377 | 422 | params: dict[str, Any] = {"level": level, "data": data} |
378 | 423 | if logger is not None: |
379 | 424 | params["logger"] = logger |
|
0 commit comments