Skip to content

Commit 2636cb9

Browse files
committed
Keep the log-delivery opt-in request-only and drop the mock from the logging test
A notification carries no request to opt in and no response stream to carry a log entry, so its _meta no longer opens the per-request log gate: _make_context passes the log-level opt-in to ServerSession only for requests. Regression-tested over the dual-era stream loop. Merge the opt-in and threshold interaction tests into one two-phase test sharing a real closure callback instead of an AsyncMock.
1 parent 84724a4 commit 2636cb9

3 files changed

Lines changed: 39 additions & 23 deletions

File tree

src/mcp/server/runner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,10 @@ def _make_context(
321321
# Per-request session: `dctx` is the request-scoped channel (auto-threads
322322
# its own request_id on streamable HTTP); the standalone channel is read
323323
# off `connection.outbound`. `related_request_id` on the public API selects.
324-
# `meta` carries this request's log-level opt-in for the session's log gate.
325-
session = ServerSession(dctx, self.connection, request_meta=meta)
324+
# `meta` carries a request's log-level opt-in for the session's log gate. A
325+
# notification has no request to opt in (and no response stream to carry
326+
# the log entry), so its `_meta` never opens the gate.
327+
session = ServerSession(dctx, self.connection, request_meta=meta if dctx.request_id is not None else None)
326328
return ServerRequestContext(
327329
session=session,
328330
lifespan_context=self.lifespan_state,

tests/interaction/lowlevel/test_logging.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
streamable HTTP, so plain-list collection is deterministic on every transport leg.
66
"""
77

8-
from unittest.mock import AsyncMock
9-
108
import mcp_types as types
119
import pytest
1210
from inline_snapshot import snapshot
@@ -160,33 +158,28 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
160158

161159

162160
@requirement("logging:per-request:opt-in")
163-
async def test_no_log_messages_are_sent_for_a_request_without_a_log_level(connect: Connect) -> None:
164-
"""A request whose _meta carries no io.modelcontextprotocol/logLevel gets no log messages at all.
161+
@requirement("logging:per-request:threshold")
162+
async def test_log_delivery_follows_the_per_request_log_level(connect: Connect) -> None:
163+
"""Without io.modelcontextprotocol/logLevel in _meta a request gets no log messages;
164+
with it, only entries at or above the requested level are delivered, in order.
165165
166-
The handler emits at every severity, but the client never opts in, so the server must send
167-
nothing: the log calls are dropped rather than delivered on some other stream.
166+
The handler emits at every severity in both phases: the un-opted request receives
167+
nothing (the log calls are dropped, not delivered on some other stream), and the request
168+
opting in at `warning` receives warning and above.
168169
"""
169-
logging_callback = AsyncMock()
170+
received: list[types.LoggingLevel] = []
170171

171-
async with connect(_siren_server(), logging_callback=logging_callback) as client:
172-
result = await client.call_tool("siren", {})
172+
async def collect(params: LoggingMessageNotificationParams) -> None:
173+
received.append(params.level)
173174

175+
async with connect(_siren_server(), logging_callback=collect) as client:
176+
result = await client.call_tool("siren", {})
174177
assert isinstance(result.content[0], TextContent) and result.content[0].text == "logged"
175-
logging_callback.assert_not_awaited()
176-
177-
178-
@requirement("logging:per-request:threshold")
179-
async def test_log_messages_below_the_requested_level_are_dropped(connect: Connect) -> None:
180-
"""A request opting in at `warning` receives only warning and above, in order."""
181-
received: list[LoggingMessageNotificationParams] = []
182-
183-
async def collect(params: LoggingMessageNotificationParams) -> None:
184-
received.append(params)
178+
assert received == []
185179

186180
async with connect(_siren_server(), logging_callback=collect, log_level="warning") as client:
187181
await client.call_tool("siren", {})
188-
189-
assert [params.level for params in received] == ["warning", "error", "critical", "alert", "emergency"]
182+
assert received == ["warning", "error", "critical", "alert", "emergency"]
190183

191184

192185
@requirement("logging:per-request:invalid-level")

tests/server/test_runner.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
INVALID_PARAMS,
2626
INVALID_REQUEST,
2727
LATEST_PROTOCOL_VERSION,
28+
LOG_LEVEL_META_KEY,
2829
METHOD_NOT_FOUND,
2930
PROTOCOL_VERSION_META_KEY,
3031
SERVER_INFO_META_KEY,
@@ -1759,6 +1760,26 @@ async def on_custom(ctx: Ctx, params: NotificationParams | None) -> None:
17591760
await handled.wait()
17601761

17611762

1763+
@pytest.mark.anyio
1764+
async def test_dual_era_loop_a_notifications_meta_never_opens_the_log_gate(server: SrvT):
1765+
"""The 2026 log-delivery opt-in is a request's `_meta`: an inbound notification
1766+
has no request to opt in, so a handler logging in response to one - even
1767+
with the log-level key in the notification's own `_meta` - sends nothing."""
1768+
logged = anyio.Event()
1769+
1770+
async def log_it(ctx: Ctx, params: NotificationParams | None) -> None:
1771+
await ctx.session.send_log_message("emergency", "no request opted in") # pyright: ignore[reportDeprecated]
1772+
logged.set()
1773+
1774+
server.add_notification_handler("notifications/custom", NotificationParams, log_it)
1775+
async with dual_era_client(server) as (client, recorder):
1776+
await client.send_raw_request("tools/list", _modern_params())
1777+
meta = {**_modern_envelope(), LOG_LEVEL_META_KEY: "debug"}
1778+
await client.notify("notifications/custom", {"_meta": meta})
1779+
await logged.wait()
1780+
assert [method for method, _ in recorder.notifications] == []
1781+
1782+
17621783
@pytest.mark.anyio
17631784
async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT):
17641785
"""A modern handler's standalone notification reaches the client over the

0 commit comments

Comments
 (0)