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

@ -21,6 +21,7 @@ from mcp_types import (
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
LoggingLevel,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
@ -68,6 +69,7 @@ class Connect(Protocol):
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@ -84,6 +86,7 @@ async def connect_in_memory(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@ -103,6 +106,7 @@ async def connect_in_memory(
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
@ -123,6 +127,7 @@ async def connect_over_streamable_http(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@ -158,6 +163,7 @@ async def connect_over_streamable_http(
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
@ -232,6 +238,7 @@ async def client_via_http(
http_client: httpx2.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
@ -248,6 +255,7 @@ async def client_via_http(
# closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy",
logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler,
elicitation_callback=elicitation_callback,
) as client:
@ -360,6 +368,7 @@ async def connect_over_sse(
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
@ -394,6 +403,7 @@ async def connect_over_sse(
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,

View file

@ -1522,6 +1522,7 @@ REQUIREMENTS: dict[str, Requirement] = {
),
),
removed_in="2026-07-28",
superseded_by="logging:per-request:threshold",
note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta."
@ -1531,6 +1532,7 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
behavior="logging/setLevel delivers the requested level to the server's handler and returns an empty result.",
removed_in="2026-07-28",
superseded_by="logging:per-request:opt-in",
note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta."
@ -1540,11 +1542,40 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/server/utilities/logging#error-handling",
behavior="logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).",
removed_in="2026-07-28",
superseded_by="logging:per-request:invalid-level",
note=(
"removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request "
"io.modelcontextprotocol/logLevel in _meta."
),
),
"logging:per-request:opt-in": Requirement(
source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level",
behavior=(
"The server does not send log message notifications for a request unless the request opts in by "
"carrying io.modelcontextprotocol/logLevel in _meta; a handler's log calls on an un-opted "
"request are dropped, not delivered on another stream."
),
added_in="2026-07-28",
supersedes=("logging:set-level",),
),
"logging:per-request:threshold": Requirement(
source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level",
behavior=(
"A request that opts in receives log message notifications only at or above the level named in "
"its io.modelcontextprotocol/logLevel; entries below the level are dropped."
),
added_in="2026-07-28",
supersedes=("logging:message:filtered",),
),
"logging:per-request:invalid-level": Requirement(
source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#error-handling",
behavior=(
"A request whose io.modelcontextprotocol/logLevel is not a recognized log level is rejected with "
"JSON-RPC error -32602 (Invalid params)."
),
added_in="2026-07-28",
supersedes=("logging:set-level:invalid-level",),
),
# ═══════════════════════════════════════════════════════════════════════════
# Sampling (server → client)
# ═══════════════════════════════════════════════════════════════════════════

View file

@ -8,8 +8,16 @@ streamable HTTP, so plain-list collection is deterministic on every transport le
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
from mcp_types import (
INVALID_PARAMS,
LOG_LEVEL_META_KEY,
CallToolResult,
EmptyResult,
LoggingMessageNotificationParams,
TextContent,
)
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
@ -81,7 +89,7 @@ async def test_log_messages_reach_logging_callback_in_order(connect: Connect, un
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async with connect(server, logging_callback=collect) as client:
async with connect(server, logging_callback=collect, log_level="debug") as client:
result = await client.call_tool("chatty", {})
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")]))
@ -122,7 +130,63 @@ async def test_log_messages_at_every_severity_level(connect: Connect) -> None:
"logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async with connect(server, logging_callback=collect) as client:
async with connect(server, logging_callback=collect, log_level="debug") as client:
await client.call_tool("siren", {})
assert [params.level for params in received] == list(ALL_LEVELS)
def _siren_server() -> Server:
"""A server whose `siren` tool logs one message at each of the eight severity levels.
The messages are sent without `related_request_id`: on 2026-07-28+ log delivery is
request-scoped by construction, so they still ride the requesting stream on every leg.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="siren", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "siren"
for level in ALL_LEVELS:
await ctx.session.send_log_message(level=level, data=f"a {level} message") # pyright: ignore[reportDeprecated]
return CallToolResult(content=[TextContent(text="logged")])
return Server("logger", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("logging:per-request:opt-in")
@requirement("logging:per-request:threshold")
async def test_log_delivery_follows_the_per_request_log_level(connect: Connect) -> None:
"""Without io.modelcontextprotocol/logLevel in _meta a request gets no log messages;
with it, only entries at or above the requested level are delivered, in order.
The handler emits at every severity in both phases: the un-opted request receives
nothing (the log calls are dropped, not delivered on some other stream), and the request
opting in at `warning` receives warning and above.
"""
received: list[types.LoggingLevel] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params.level)
async with connect(_siren_server(), logging_callback=collect) as client:
result = await client.call_tool("siren", {})
assert isinstance(result.content[0], TextContent) and result.content[0].text == "logged"
assert received == []
async with connect(_siren_server(), logging_callback=collect, log_level="warning") as client:
await client.call_tool("siren", {})
assert received == ["warning", "error", "critical", "alert", "emergency"]
@requirement("logging:per-request:invalid-level")
async def test_a_request_with_an_unrecognized_log_level_is_rejected(connect: Connect) -> None:
"""A request whose _meta names an unrecognized log level is rejected with -32602 before the handler runs."""
async with connect(_siren_server()) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("siren", {}, meta={LOG_LEVEL_META_KEY: "verbose"})
assert exc_info.value.error.code == INVALID_PARAMS

View file

@ -50,7 +50,7 @@ async def test_context_logging_helpers_send_log_notifications(connect: Connect)
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params)
async with connect(mcp, logging_callback=collect) as client:
async with connect(mcp, logging_callback=collect, log_level="debug") as client:
result = await client.call_tool("narrate", {})
advertised_logging = client.server_capabilities.logging
@ -142,7 +142,7 @@ async def test_report_progress_without_a_progress_token_sends_nothing(connect: C
async def collect(message: IncomingMessage) -> None:
received.append(message)
async with connect(mcp, message_handler=collect) as client:
async with connect(mcp, message_handler=collect, log_level="debug") as client:
result = await client.call_tool("mill", {})
assert unstamped(result) == snapshot(

View file

@ -431,7 +431,7 @@ async def test_adding_and_removing_tools_does_not_notify_connected_clients(conne
async def collect(message: IncomingMessage) -> None:
received.append(message)
async with connect(mcp, message_handler=collect) as client:
async with connect(mcp, message_handler=collect, log_level="debug") as client:
before = await client.list_tools()
await client.call_tool("grow", {})
after = await client.list_tools()