Gate log notifications on the per-request log-level opt-in at 2026-07-28 (#3198)
This commit is contained in:
parent
b61ce388dd
commit
89c5e700f2
11 changed files with 406 additions and 31 deletions
|
|
@ -13,6 +13,7 @@ from mcp_types import (
|
|||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
LOG_LEVEL_META_KEY,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
REQUEST_TIMEOUT,
|
||||
|
|
@ -1548,6 +1549,21 @@ async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp
|
|||
assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_log_level_opt_in_is_stamped_on_modern_requests_and_overridable_per_call() -> None:
|
||||
"""SDK-defined: `log_level` stamps the reserved log-level `_meta` key on every modern
|
||||
request, and a request supplying that key in its own `_meta` overrides the default."""
|
||||
dispatcher = _ScriptedDispatcher(_discover_result_dict(), {}, {})
|
||||
with anyio.fail_after(5):
|
||||
async with ClientSession(dispatcher=dispatcher, log_level="warning") as session:
|
||||
await session.discover()
|
||||
await session.send_ping()
|
||||
await session.send_ping(meta={LOG_LEVEL_META_KEY: "debug"})
|
||||
default_meta, override_meta = (params["_meta"] for _, params in dispatcher.calls[-2:] if params is not None)
|
||||
assert default_meta[LOG_LEVEL_META_KEY] == "warning"
|
||||
assert override_meta[LOG_LEVEL_META_KEY] == "debug"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None:
|
||||
"""Spec SHOULD: a -32022 reply that names a mutually-supported version
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue