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
5 changed files with 406 additions and 31 deletions

View file

@ -283,6 +283,16 @@ async def test_connection_log_sends_logging_message_notification():
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
async def test_connection_log_with_meta_includes_meta_in_params():
out = StubOutbound()

View file

@ -25,6 +25,7 @@ from mcp_types import (
INVALID_PARAMS,
INVALID_REQUEST,
LATEST_PROTOCOL_VERSION,
LOG_LEVEL_META_KEY,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_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()
@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
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

View file

@ -11,6 +11,8 @@ from typing import Any
import anyio
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.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"
@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
async def test_context_log_includes_logger_and_meta_when_supplied():
crec = Recorder()

View file

@ -12,6 +12,7 @@ from typing import Any
import mcp_types as types
import pytest
from mcp_types import (
LOG_LEVEL_META_KEY,
ClientCapabilities,
Implementation,
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"]
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
async def test_report_progress_delegates_to_the_request_dispatch_context():
"""`report_progress` calls the per-request `DispatchContext.progress` seam, never the

View file

@ -12,7 +12,7 @@ from typing import Any
import mcp_types as types
import pytest
from mcp_types import LATEST_PROTOCOL_VERSION
from mcp_types import LATEST_PROTOCOL_VERSION, LOG_LEVEL_META_KEY
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
@ -53,13 +53,15 @@ class StubOutbound:
raise NotImplementedError # pragma: no cover
def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
def _no_channel_session(
request_ch: StubOutbound | None = None, *, request_meta: types.RequestParamsMeta | None = None
) -> tuple[ServerSession, StubOutbound]:
"""A session whose standalone channel is the connection's no-channel
sentinel; the request channel is a working stub."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
assert conn.has_standalone_channel is False
request = request_ch if request_ch is not None else StubOutbound()
return ServerSession(request, conn), request
return ServerSession(request, conn, request_meta=request_meta), request
@pytest.fixture
@ -141,10 +143,11 @@ async def test_elicit_form_with_related_id_rides_the_request_channel():
@pytest.mark.anyio
async def test_send_log_message_with_related_id_rides_the_request_channel():
"""SDK-defined: the deprecated ``send_log_message`` notification with a related id
rides the per-request channel, so it is delivered even with no standalone back-channel."""
session, request_ch = _no_channel_session()
async def test_send_log_message_rides_the_request_channel_when_opted_in():
"""SDK-defined: the deprecated `send_log_message` notification rides the per-request
channel on a 2026 connection (log delivery is request-scoped by spec), so it is delivered
even with no standalone back-channel - once the request opted in via its `_meta`."""
session, request_ch = _no_channel_session(request_meta={LOG_LEVEL_META_KEY: "debug"})
await session.send_log_message( # pyright: ignore[reportDeprecated]
level="info", data="hello", logger="test", related_request_id=3
)