1206 lines
54 KiB
Python
1206 lines
54 KiB
Python
"""Tests for `ServerRunner`.
|
|
|
|
End-to-end over `JSONRPCDispatcher` with a real lowlevel `Server` as the
|
|
registry. The `connected_runner` helper starts both sides and (by default)
|
|
performs the initialize handshake, so each test exercises only the behaviour
|
|
under test.
|
|
"""
|
|
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any, cast
|
|
|
|
import anyio
|
|
import pytest
|
|
from opentelemetry.trace import SpanKind, StatusCode
|
|
|
|
import mcp.server.runner
|
|
from mcp.server.context import ServerRequestContext
|
|
from mcp.server.lowlevel.server import NotificationOptions, Server
|
|
from mcp.server.models import InitializationOptions
|
|
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
|
|
from mcp.server.session import ServerSession
|
|
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
|
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
|
|
from mcp.shared.peer import dump_params
|
|
from mcp.shared.transport_context import TransportContext
|
|
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
|
from mcp.types import (
|
|
INTERNAL_ERROR,
|
|
INVALID_PARAMS,
|
|
LATEST_PROTOCOL_VERSION,
|
|
METHOD_NOT_FOUND,
|
|
PROTOCOL_VERSION_META_KEY,
|
|
CallToolRequestParams,
|
|
ClientCapabilities,
|
|
ErrorData,
|
|
Implementation,
|
|
InitializeRequestParams,
|
|
ListToolsResult,
|
|
NotificationParams,
|
|
PaginatedRequestParams,
|
|
ProgressNotificationParams,
|
|
RequestParams,
|
|
RequestParamsMeta,
|
|
SetLevelRequestParams,
|
|
Tool,
|
|
)
|
|
|
|
from ..shared.conftest import jsonrpc_pair
|
|
from ..shared.test_dispatcher import Recorder, echo_handlers
|
|
from .conftest import SpanCapture
|
|
|
|
Ctx = ServerRequestContext[dict[str, Any], Any]
|
|
|
|
|
|
def _initialize_params() -> dict[str, Any]:
|
|
return InitializeRequestParams(
|
|
protocol_version=LATEST_PROTOCOL_VERSION,
|
|
capabilities=ClientCapabilities(),
|
|
client_info=Implementation(name="test-client", version="1.0"),
|
|
).model_dump(by_alias=True, exclude_none=True)
|
|
|
|
|
|
_seen_ctx: list[Ctx] = []
|
|
SrvT = Server[dict[str, Any]]
|
|
|
|
|
|
@pytest.fixture
|
|
def server() -> SrvT:
|
|
"""A lowlevel Server with one tools/list handler registered."""
|
|
_seen_ctx.clear()
|
|
|
|
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
_seen_ctx.append(ctx)
|
|
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
|
|
|
|
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def connected_runner(
|
|
server: SrvT,
|
|
*,
|
|
initialized: bool = True,
|
|
stateless: bool = False,
|
|
has_standalone_channel: bool = True,
|
|
init_options: InitializationOptions | None = None,
|
|
session_id: str | None = None,
|
|
dispatch_middleware: list[DispatchMiddleware] | None = None,
|
|
) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], ServerRunner[dict[str, Any]]]]:
|
|
"""Yield `(client, runner)` running over an in-memory JSON-RPC dispatcher pair.
|
|
|
|
Starts the client (echo handlers) and `runner.run()` in a task group, wraps
|
|
the body in `anyio.fail_after(5)`, and cancels on exit. When
|
|
`initialized` is true the helper performs the real `initialize` request
|
|
before yielding, so tests start past the init-gate via the public path.
|
|
"""
|
|
client, server_d, close = jsonrpc_pair()
|
|
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
|
|
runner = ServerRunner(
|
|
server=server,
|
|
dispatcher=server_d,
|
|
lifespan_state={},
|
|
has_standalone_channel=has_standalone_channel,
|
|
init_options=init_options,
|
|
session_id=session_id,
|
|
stateless=stateless,
|
|
dispatch_middleware=dispatch_middleware or [],
|
|
)
|
|
c_req, c_notify = echo_handlers(Recorder())
|
|
body_exc: BaseException | None = None
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(client.run, c_req, c_notify)
|
|
await tg.start(runner.run)
|
|
try:
|
|
with anyio.fail_after(5):
|
|
if initialized:
|
|
await client.send_raw_request("initialize", _initialize_params())
|
|
yield client, runner
|
|
except BaseException as e:
|
|
# Capture and re-raise outside the task group so test failures
|
|
# surface as the original exception, not an ExceptionGroup wrapper.
|
|
body_exc = e
|
|
close()
|
|
if body_exc is not None:
|
|
raise body_exc
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_connected_runner_propagates_body_exception_unwrapped(server: SrvT):
|
|
"""The harness re-raises body exceptions as-is, not as `ExceptionGroup`."""
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
async with connected_runner(server):
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_handles_initialize_and_populates_connection(server: SrvT):
|
|
async with connected_runner(server, initialized=False) as (client, runner):
|
|
result = await client.send_raw_request("initialize", _initialize_params())
|
|
assert result["serverInfo"]["name"] == "test-server"
|
|
assert "tools" in result["capabilities"]
|
|
assert runner.connection.client_params is not None
|
|
assert runner.connection.client_params.client_info.name == "test-client"
|
|
assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION
|
|
assert runner.connection.initialize_accepted is True
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_initialize_opens_gate_but_event_fires_only_after_initialized_notification(server: SrvT):
|
|
"""`initialize` commits the gate flag and peer info, but the public
|
|
`connection.initialized` event waits for `notifications/initialized` (the
|
|
point from which the spec permits server-initiated requests)."""
|
|
async with connected_runner(server, initialized=False) as (client, runner):
|
|
await client.send_raw_request("initialize", _initialize_params())
|
|
assert runner.connection.initialize_accepted is True
|
|
assert not runner.connection.initialized.is_set()
|
|
await client.notify("notifications/initialized", None)
|
|
await runner.connection.initialized.wait()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_gates_requests_before_initialize(server: SrvT):
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
|
# ping is exempt from the gate
|
|
assert await client.send_raw_request("ping", None) == {}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_unknown_method_before_initialize_raises_method_not_found(server: SrvT):
|
|
"""An unknown method is METHOD_NOT_FOUND even before initialize: JSON-RPC
|
|
2.0 reserves -32601 for it, and clients probing a server before the
|
|
handshake key off that code. The init gate only applies to methods the
|
|
server actually serves."""
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("x/unknown", None)
|
|
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_spec_method_without_handler_before_initialize_raises_method_not_found(server: SrvT):
|
|
"""A spec method the server doesn't serve is METHOD_NOT_FOUND even before
|
|
initialize: -32601 means "not available on this server", so probing
|
|
clients get the same answer in every initialization state (the fixture
|
|
server registers no resources handlers)."""
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("resources/list", None)
|
|
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/list")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_custom_method_with_handler_is_still_gated_before_initialize(server: SrvT):
|
|
"""A custom-registered method is a known method: before initialize it is
|
|
rejected by the init gate, not answered with METHOD_NOT_FOUND."""
|
|
|
|
async def greet(ctx: Ctx, params: RequestParams | None) -> Any:
|
|
raise NotImplementedError # the gate rejects the request first
|
|
|
|
server.add_request_handler("custom/greet", RequestParams, greet)
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("custom/greet", None)
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
|
|
async with connected_runner(server) as (client, runner):
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result["tools"][0]["name"] == "t"
|
|
ctx = _seen_ctx[0]
|
|
assert isinstance(ctx, ServerRequestContext)
|
|
assert ctx.lifespan_context == {}
|
|
assert isinstance(ctx.session, ServerSession)
|
|
assert ctx.session is runner.session
|
|
assert ctx.request_id is not None
|
|
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_spec_method_with_no_handler_raises_method_not_found(server: SrvT):
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("resources/list", None)
|
|
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/list")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_non_spec_method_with_no_handler_raises_method_not_found(server: SrvT):
|
|
"""Upfront validation is gated to spec methods, so a non-spec method
|
|
skips it and reaches handler lookup."""
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("nonexistent/method", None)
|
|
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="nonexistent/method")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_malformed_params_for_unregistered_spec_method_raises_invalid_params(server: SrvT):
|
|
"""A spec method with malformed params is INVALID_PARAMS even with no handler."""
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/call", {"name": 123})
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_rejects_snake_case_initialize_params(server: SrvT):
|
|
"""Inbound wire payloads validate alias-only; Python field names are not
|
|
accepted (`protocol_version` must arrive as `protocolVersion`)."""
|
|
snake = {
|
|
"protocol_version": LATEST_PROTOCOL_VERSION,
|
|
"capabilities": {},
|
|
"client_info": {"name": "c", "version": "0"},
|
|
}
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("initialize", snake)
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_initialize_with_absent_params_returns_invalid_params_and_stays_alive(server: SrvT):
|
|
"""Re-covers what the old `tests/issues/test_malformed_input.py` pinned: a
|
|
malformed `initialize` is rejected and the runner keeps serving."""
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("initialize", None)
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
result = await client.send_raw_request("initialize", _initialize_params())
|
|
assert result["serverInfo"]["name"] == "test-server"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_rejects_snake_case_params_for_custom_handler(server: SrvT):
|
|
"""Custom-method handlers (which skip the spec-method gate) still validate
|
|
alias-only at the per-handler boundary."""
|
|
|
|
async def handler(ctx: Ctx, params: ProgressNotificationParams) -> dict[str, Any]:
|
|
return {"ok": True}
|
|
|
|
server.add_request_handler("custom/progress", ProgressNotificationParams, handler)
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("custom/progress", {"progress_token": 1, "progress": 0.5})
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
result = await client.send_raw_request("custom/progress", {"progressToken": 1, "progress": 0.5})
|
|
assert result == {"ok": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_drops_snake_case_params(server: SrvT, caplog: pytest.LogCaptureFixture):
|
|
"""Notification params validate alias-only; snake_case is dropped as malformed."""
|
|
|
|
async def handler(ctx: Ctx, params: ProgressNotificationParams) -> None:
|
|
raise NotImplementedError
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", ProgressNotificationParams, handler)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.notify("notifications/roots/list_changed", {"progress_token": 1, "progress": 0.5})
|
|
await client.send_raw_request("tools/list", None)
|
|
assert "dropped 'notifications/roots/list_changed': malformed params" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_drops_a_spec_notification_absent_at_the_negotiated_version(
|
|
server: SrvT, caplog: pytest.LogCaptureFixture
|
|
):
|
|
"""`notifications/roots/list_changed` is a client notification but not at
|
|
2026-07-28; the version gate drops it before handler lookup."""
|
|
barrier = anyio.Event()
|
|
|
|
async def dropped(ctx: Ctx, params: NotificationParams) -> None:
|
|
raise NotImplementedError # the version gate drops the notification first
|
|
|
|
async def on_progress(ctx: Ctx, params: ProgressNotificationParams) -> None:
|
|
barrier.set()
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, dropped)
|
|
server.add_notification_handler("notifications/progress", ProgressNotificationParams, on_progress)
|
|
with caplog.at_level("DEBUG", logger="mcp.server.runner"):
|
|
async with connected_runner(server) as (client, runner):
|
|
runner.connection.protocol_version = "2026-07-28"
|
|
await client.notify("notifications/roots/list_changed", None)
|
|
await client.notify("notifications/progress", {"progressToken": 1, "progress": 0.5})
|
|
await barrier.wait()
|
|
assert "dropped 'notifications/roots/list_changed': not defined at 2026-07-28" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT):
|
|
"""`notifications/message` is a spec method but server-to-client only; on
|
|
a server it is a custom registration (proxy use) and must reach the
|
|
handler, not the client-direction version gate."""
|
|
seen: list[NotificationParams] = []
|
|
|
|
async def handler(ctx: Ctx, params: NotificationParams) -> None:
|
|
seen.append(params)
|
|
|
|
server.add_notification_handler("notifications/message", NotificationParams, handler)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.notify("notifications/message", {"level": "info", "data": "x"})
|
|
await client.send_raw_request("tools/list", None)
|
|
assert len(seen) == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_initialized_sets_flag_and_connection_event(server: SrvT):
|
|
async with connected_runner(server, initialized=False) as (client, runner):
|
|
await client.notify("notifications/initialized", None)
|
|
await runner.connection.initialized.wait()
|
|
assert runner.connection.initialize_accepted is True
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_malformed_initialized_does_not_initialize(
|
|
server: SrvT, caplog: pytest.LogCaptureFixture
|
|
):
|
|
"""A malformed `notifications/initialized` drops like any other malformed
|
|
notification and leaves the connection uninitialized."""
|
|
async with connected_runner(server, initialized=False) as (client, runner):
|
|
await client.notify("notifications/initialized", {"_meta": 42})
|
|
await anyio.wait_all_tasks_blocked()
|
|
assert runner.connection.initialize_accepted is False
|
|
assert not runner.connection.initialized.is_set()
|
|
assert "dropped 'notifications/initialized': malformed params" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_initialized_routes_to_registered_handler_after_state_set(server: SrvT):
|
|
"""A handler registered for `notifications/initialized` fires after the
|
|
runner flips the init state, so it observes an initialized connection."""
|
|
seen: list[bool] = []
|
|
delivered = anyio.Event()
|
|
|
|
async def on_initialized(ctx: Ctx, params: NotificationParams | None) -> None:
|
|
seen.append(runner.connection.initialize_accepted and runner.connection.initialized.is_set())
|
|
delivered.set()
|
|
|
|
server.add_notification_handler("notifications/initialized", NotificationParams, on_initialized)
|
|
async with connected_runner(server, initialized=False) as (client, runner):
|
|
await client.notify("notifications/initialized", {"_meta": {"k": "v"}})
|
|
await delivered.wait()
|
|
assert seen == [True]
|
|
|
|
|
|
def test_server_add_request_handler_rejects_initialize():
|
|
async def handler(ctx: Ctx, params: InitializeRequestParams) -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
server: SrvT = Server(name="s")
|
|
with pytest.raises(ValueError, match="Server.middleware"):
|
|
server.add_request_handler("initialize", InitializeRequestParams, handler)
|
|
assert server.get_request_handler("initialize") is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_routes_to_registered_handler(server: SrvT):
|
|
seen: list[tuple[Any, Any]] = []
|
|
delivered = anyio.Event()
|
|
|
|
async def on_roots_changed(ctx: Ctx, params: NotificationParams | None) -> None:
|
|
seen.append((ctx, params))
|
|
if len(seen) == 2:
|
|
delivered.set()
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots_changed)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.notify("notifications/roots/list_changed", None)
|
|
await client.notify("notifications/roots/list_changed", {})
|
|
await delivered.wait()
|
|
assert isinstance(seen[0][0], ServerRequestContext)
|
|
# Absent and present-but-empty wire params both validate to the defaults model.
|
|
assert seen[0][1] == NotificationParams()
|
|
assert isinstance(seen[1][1], NotificationParams)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_handler_exception_is_swallowed_and_logged(
|
|
server: SrvT, caplog: pytest.LogCaptureFixture
|
|
):
|
|
"""A notification handler crashing must not tear down the connection."""
|
|
|
|
async def boom(ctx: Ctx, params: NotificationParams | None) -> None:
|
|
raise RuntimeError("notification handler boom")
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, boom)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.notify("notifications/roots/list_changed", None)
|
|
# Connection still alive: a request after the crashing handler succeeds.
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result["tools"][0]["name"] == "t"
|
|
assert "notification handler for 'notifications/roots/list_changed' raised" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_drops_malformed_params(server: SrvT, caplog: pytest.LogCaptureFixture):
|
|
"""Malformed notification params are logged and dropped, not raised."""
|
|
|
|
async def on_level(ctx: Ctx, params: SetLevelRequestParams) -> None:
|
|
raise NotImplementedError
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", SetLevelRequestParams, on_level)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.notify("notifications/roots/list_changed", {"level": "not-a-level"})
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result["tools"][0]["name"] == "t"
|
|
assert "dropped 'notifications/roots/list_changed': malformed params" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_drops_absent_params_when_model_requires_them(
|
|
server: SrvT, caplog: pytest.LogCaptureFixture
|
|
):
|
|
"""A params-less progress notification is dropped, not delivered as None.
|
|
|
|
`on_progress` is typed to receive a non-Optional `ProgressNotificationParams`;
|
|
the previous server validated the full notification union and dropped this
|
|
as malformed before dispatch.
|
|
"""
|
|
|
|
async def on_progress(ctx: Ctx, params: ProgressNotificationParams) -> None:
|
|
raise NotImplementedError
|
|
|
|
server.add_notification_handler("notifications/progress", ProgressNotificationParams, on_progress)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.notify("notifications/progress", None)
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result["tools"][0]["name"] == "t"
|
|
assert "dropped 'notifications/progress': malformed params" in caplog.text
|
|
assert "notification handler for" not in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_absent_wire_params_reaches_request_handler_as_defaults_model():
|
|
"""A request with no `params` member on the wire reaches the handler as
|
|
the params model with its defaults, never `None`.
|
|
|
|
The in-SDK client always attaches `_meta`, so a dispatch middleware
|
|
forwards `params=None` to model what an external client sends.
|
|
"""
|
|
seen: list[PaginatedRequestParams | None] = []
|
|
|
|
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
seen.append(params)
|
|
return ListToolsResult(tools=[])
|
|
|
|
def drop_params(next_on_request: OnRequest) -> OnRequest:
|
|
async def wrapped(dctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]:
|
|
return await next_on_request(dctx, method, None if method == "tools/list" else params)
|
|
|
|
return wrapped
|
|
|
|
server: SrvT = Server(name="s", on_list_tools=list_tools)
|
|
async with connected_runner(server, dispatch_middleware=[drop_params]) as (client, _):
|
|
await client.send_raw_request("tools/list", None)
|
|
assert seen == [PaginatedRequestParams()]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_absent_wire_params_for_required_params_custom_method_is_invalid_params():
|
|
"""A custom method whose `params_type` has required fields rejects absent
|
|
wire params as INVALID_PARAMS rather than invoking the handler with None."""
|
|
|
|
class GreetParams(RequestParams):
|
|
name: str
|
|
|
|
async def greet(ctx: Ctx, params: GreetParams) -> dict[str, Any]:
|
|
raise NotImplementedError
|
|
|
|
def drop_params(next_on_request: OnRequest) -> OnRequest:
|
|
async def wrapped(dctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]:
|
|
return await next_on_request(dctx, method, None if method == "custom/greet" else params)
|
|
|
|
return wrapped
|
|
|
|
server: SrvT = Server(name="s")
|
|
server.add_request_handler("custom/greet", GreetParams, greet)
|
|
async with connected_runner(server, dispatch_middleware=[drop_params]) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("custom/greet", {"name": "x"})
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_on_notify_drops_before_init_and_unknown_methods(server: SrvT):
|
|
seen: list[Any] = []
|
|
|
|
async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
|
|
seen.append(params)
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots)
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
await client.notify("notifications/roots/list_changed", None) # before init: dropped
|
|
await client.notify("notifications/initialized", None)
|
|
await client.notify("notifications/unknown", None) # no handler: dropped
|
|
await client.notify("notifications/roots/list_changed", None) # post-init: delivered
|
|
await anyio.wait_all_tasks_blocked()
|
|
assert seen == [NotificationParams()] # only the post-init one reached the handler
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_dispatch_middleware_wraps_everything_including_initialize(server: SrvT):
|
|
seen_methods: list[str] = []
|
|
|
|
def trace_mw(next_on_request: Any) -> Any:
|
|
async def wrapped(dctx: Any, method: str, params: Any) -> Any:
|
|
seen_methods.append(method)
|
|
return await next_on_request(dctx, method, params)
|
|
|
|
return wrapped
|
|
|
|
async with connected_runner(server, dispatch_middleware=[trace_mw]) as (client, _):
|
|
await client.send_raw_request("tools/list", None)
|
|
assert seen_methods == ["initialize", "tools/list"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_server_middleware_wraps_every_request_including_initialize(server: SrvT):
|
|
seen: list[tuple[str, Any]] = []
|
|
|
|
async def ctx_mw(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
seen.append((method, params))
|
|
return await call_next()
|
|
|
|
server.middleware.append(ctx_mw)
|
|
async with connected_runner(server) as (client, _):
|
|
await client.send_raw_request("ping", None)
|
|
await client.send_raw_request("tools/list", {"_meta": {"k": "v"}})
|
|
assert [m for m, _ in seen] == ["initialize", "ping", "tools/list"]
|
|
# params arrive raw (Mapping), not as a validated model
|
|
assert seen[2][1] == {"_meta": {"k": "v"}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_middleware_raise_after_call_next_on_initialize_leaves_connection_uninitialized(server: SrvT):
|
|
"""A middleware failure after `call_next()` on `initialize` reaches the
|
|
client as an error and skips the state commit: the pre-init gate stays
|
|
closed and `connection.initialized` never fires."""
|
|
|
|
async def reject_initialize(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
result = await call_next()
|
|
if method == "initialize":
|
|
raise MCPError(code=INTERNAL_ERROR, message="rejected by middleware")
|
|
return result
|
|
|
|
server.middleware.append(reject_initialize)
|
|
async with connected_runner(server, initialized=False) as (client, runner):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("initialize", _initialize_params())
|
|
assert exc.value.error.message == "rejected by middleware"
|
|
with pytest.raises(MCPError) as gate_exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert gate_exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
|
# ping passes through the middleware untouched
|
|
assert await client.send_raw_request("ping", None) == {}
|
|
assert runner.connection.initialize_accepted is False
|
|
assert runner.connection.client_params is None
|
|
assert runner.connection.protocol_version is None
|
|
assert not runner.connection.initialized.is_set()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_server_middleware_observes_method_not_found_via_call_next_raise(server: SrvT):
|
|
seen: list[tuple[str, type[BaseException] | None]] = []
|
|
|
|
async def observe(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
try:
|
|
return await call_next()
|
|
except MCPError as e:
|
|
seen.append((method, type(e)))
|
|
raise
|
|
|
|
server.middleware.append(observe)
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("nonexistent/method", None)
|
|
assert exc.value.error.code == METHOD_NOT_FOUND
|
|
assert seen == [("nonexistent/method", MCPError)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_server_middleware_wraps_notifications(server: SrvT):
|
|
"""The same chain wraps `_on_notify`: it sees `notifications/initialized`,
|
|
pre-init drops, and registered notification handlers, with
|
|
`ctx.request_id is None`."""
|
|
seen: list[tuple[str, bool]] = []
|
|
|
|
async def observe(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
seen.append((method, ctx.request_id is None))
|
|
return await call_next()
|
|
|
|
async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
|
|
return None
|
|
|
|
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots)
|
|
server.middleware.append(observe)
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
await client.notify("notifications/roots/list_changed", None) # pre-init drop, still observed
|
|
await client.notify("notifications/initialized", None)
|
|
await client.notify("notifications/roots/list_changed", None)
|
|
await anyio.wait_all_tasks_blocked()
|
|
assert seen == [
|
|
("notifications/roots/list_changed", True),
|
|
("notifications/initialized", True),
|
|
("notifications/roots/list_changed", True),
|
|
]
|
|
|
|
|
|
def test_resolve_protocol_version_handshake_committed_value_wins():
|
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
|
|
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
|
|
|
|
|
|
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
|
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
|
|
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
|
|
|
|
|
|
def test_resolve_protocol_version_skips_unsupported_meta_value():
|
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
|
|
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
|
|
|
|
|
|
def test_resolve_protocol_version_skips_non_string_meta_value():
|
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
|
|
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
|
|
|
|
|
|
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
|
|
md = ServerMessageMetadata(protocol_version="2025-06-18")
|
|
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
|
|
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
|
|
|
|
|
|
def test_resolve_protocol_version_skips_unsupported_transport_hint():
|
|
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
|
|
md = ServerMessageMetadata(protocol_version="1900-01-01")
|
|
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
|
|
|
|
|
|
def test_resolve_protocol_version_terminal_default_with_no_signals():
|
|
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
|
|
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
|
|
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
|
|
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
|
|
await client.send_raw_request("tools/list", None)
|
|
ctx = _seen_ctx[0]
|
|
assert ctx.protocol_version == "2025-11-25"
|
|
assert ctx.session.protocol_version is None
|
|
assert runner.connection.protocol_version is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
|
|
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
|
|
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
|
|
assert _seen_ctx[0].protocol_version == "2025-06-18"
|
|
|
|
|
|
def test_extract_meta_returns_none_for_absent_or_malformed():
|
|
"""Context construction is independent of `_meta` validity; the params
|
|
validation inside `call_next()` is what surfaces the error."""
|
|
assert _extract_meta(None) is None
|
|
assert _extract_meta({}) is None
|
|
assert _extract_meta({"_meta": "not-a-dict"}) is None
|
|
assert _extract_meta({"_meta": {"progressToken": []}}) is None
|
|
assert _extract_meta({"_meta": {"progressToken": "x", "k": 1}}) == {"progress_token": "x", "k": 1}
|
|
|
|
|
|
def test_extract_meta_round_trips_through_dump_params():
|
|
"""Forwarding an inbound `ctx.meta` outbound (`meta=ctx.meta`) re-emits the
|
|
wire key `progressToken`, not the Python field name `_extract_meta`
|
|
validation produced."""
|
|
meta = _extract_meta({"_meta": {"progressToken": 7, "k": 1}})
|
|
assert meta is not None
|
|
assert dump_params(None, dict(meta)) == {"_meta": {"progressToken": 7, "k": 1}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_server_middleware_runs_outermost_first(server: SrvT):
|
|
order: list[str] = []
|
|
|
|
def make_mw(tag: str) -> Any:
|
|
async def mw(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
order.append(f"{tag}-in")
|
|
result = await call_next()
|
|
order.append(f"{tag}-out")
|
|
return result
|
|
|
|
return mw
|
|
|
|
server.middleware.extend([make_mw("a"), make_mw("b")])
|
|
async with connected_runner(server) as (client, _):
|
|
order.clear() # drop the wrap of the helper's `initialize`
|
|
await client.send_raw_request("tools/list", None)
|
|
assert order == ["a-in", "b-in", "b-out", "a-out"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_handler_returning_none_yields_empty_result(server: SrvT):
|
|
async def set_level(ctx: Ctx, params: SetLevelRequestParams) -> None:
|
|
return None
|
|
|
|
server.add_request_handler("logging/setLevel", SetLevelRequestParams, set_level)
|
|
async with connected_runner(server) as (client, _):
|
|
result = await client.send_raw_request("logging/setLevel", {"level": "info"})
|
|
assert result == {}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_handler_returning_error_data_produces_jsonrpc_error(server: SrvT):
|
|
"""A handler returning `ErrorData` reaches the client as a JSON-RPC error,
|
|
not a success result, matching `BaseSession._send_response`."""
|
|
|
|
async def set_level(ctx: Ctx, params: SetLevelRequestParams) -> ErrorData:
|
|
return ErrorData(code=INVALID_PARAMS, message="bad level", data={"got": params.level})
|
|
|
|
server.add_request_handler("logging/setLevel", SetLevelRequestParams, set_level)
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("logging/setLevel", {"level": "info"})
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="bad level", data={"got": "info"})
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_server_middleware_observes_handler_error_data_as_mcp_error(server: SrvT):
|
|
"""A handler returning `ErrorData` raises `MCPError` inside `call_next()`,
|
|
so observation middleware records the failure instead of seeing a
|
|
successful-looking `ErrorData` return."""
|
|
seen: list[MCPError] = []
|
|
|
|
async def observe(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
try:
|
|
return await call_next()
|
|
except MCPError as e:
|
|
seen.append(e)
|
|
raise
|
|
|
|
async def set_level(ctx: Ctx, params: SetLevelRequestParams) -> ErrorData:
|
|
return ErrorData(code=INVALID_PARAMS, message="bad level")
|
|
|
|
server.middleware.append(observe)
|
|
server.add_request_handler("logging/setLevel", SetLevelRequestParams, set_level)
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("logging/setLevel", {"level": "info"})
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="bad level")
|
|
assert [e.error.message for e in seen] == ["bad level"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_middleware_returning_error_data_produces_jsonrpc_error(server: SrvT):
|
|
"""A middleware that short-circuits with an `ErrorData` return gets the
|
|
same treatment as a handler return: the wire sees a JSON-RPC error."""
|
|
|
|
async def short_circuit(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
return ErrorData(code=INVALID_PARAMS, message="denied")
|
|
|
|
server.middleware.append(short_circuit)
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="denied")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_handler_returning_unsupported_type_surfaces_as_error(server: SrvT):
|
|
async def bad_return(ctx: Ctx, params: PaginatedRequestParams | None) -> int:
|
|
return 42
|
|
|
|
# cast: deliberately registering a handler with a bad return type to
|
|
# exercise the runtime check; pyright would (correctly) reject it otherwise.
|
|
server.add_request_handler("tools/list", PaginatedRequestParams, cast(Any, bad_return))
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert exc.value.error.code == 0
|
|
assert "int" in exc.value.error.message
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_stateless_skips_init_gate(server: SrvT):
|
|
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (client, _):
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result["tools"][0]["name"] == "t"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_stateless_connection_initialized_event_set_on_construction(server: SrvT):
|
|
"""`connection.initialized` mirrors the gate flag in stateless mode so
|
|
`await connection.initialized.wait()` does not hang when no handshake
|
|
arrives."""
|
|
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (_, runner):
|
|
assert runner.connection.initialize_accepted is True
|
|
assert runner.connection.initialized.is_set()
|
|
await runner.connection.initialized.wait()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_server_add_request_handler_routes_custom_method_with_validated_params(server: SrvT):
|
|
"""Custom methods outside the spec `ClientRequest` union skip upfront
|
|
validation and route to the registered handler."""
|
|
|
|
class GreetParams(RequestParams):
|
|
name: str
|
|
|
|
received: list[GreetParams] = []
|
|
|
|
async def greet(ctx: Ctx, params: GreetParams) -> dict[str, Any]:
|
|
received.append(params)
|
|
return {"greeting": f"hello {params.name}"}
|
|
|
|
server.add_request_handler("custom/greet", GreetParams, greet)
|
|
async with connected_runner(server) as (client, _):
|
|
result = await client.send_raw_request("custom/greet", {"name": "world"})
|
|
assert result == {"greeting": "hello world"}
|
|
assert isinstance(received[0], GreetParams)
|
|
assert received[0].name == "world"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_spec_method_with_invalid_params_is_invalid_params_at_the_negotiated_version(server: SrvT):
|
|
async with connected_runner(server) as (client, runner):
|
|
assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/call", {"name": 42})
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_handler_returning_malformed_dict_for_spec_method_is_internal_error(server: SrvT):
|
|
async def bad_result(ctx: Ctx, params: PaginatedRequestParams | None) -> dict[str, Any]:
|
|
return {"tools": 42}
|
|
|
|
server.add_request_handler("tools/list", PaginatedRequestParams, bad_result)
|
|
async with connected_runner(server) as (client, _):
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert exc.value.error.code == INTERNAL_ERROR
|
|
assert exc.value.error.message == "Handler returned an invalid result"
|
|
# Result body must not reach the client; detail belongs in the server log.
|
|
assert exc.value.error.data is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_handler_returning_typed_monolith_result_passes_outbound_validation(server: SrvT):
|
|
async with connected_runner(server) as (client, _):
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result["tools"][0]["name"] == "t"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_outbound_sieve_drops_2026_only_result_keys_at_a_pre_2026_version(server: SrvT):
|
|
"""The handler's `resultType`/`ttlMs`/`cacheScope` are sieved out so a 2025
|
|
client sees only schema fields."""
|
|
|
|
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})], ttl_ms=5, cache_scope="public")
|
|
|
|
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
|
|
async with connected_runner(server) as (client, runner):
|
|
assert runner.connection.protocol_version == "2025-11-25"
|
|
result = await client.send_raw_request("tools/list", None)
|
|
assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT):
|
|
"""`roots/list` is a spec method but server-to-client only; on a server it
|
|
is a custom registration (proxy use) and must reach the handler, not the
|
|
client-direction version gate."""
|
|
|
|
async def list_roots(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
|
return {"roots": [{"uri": "file:///workspace"}]}
|
|
|
|
server.add_request_handler("roots/list", RequestParams, list_roots)
|
|
async with connected_runner(server) as (client, _):
|
|
result = await client.send_raw_request("roots/list", None)
|
|
assert result == {"roots": [{"uri": "file:///workspace"}]}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not_found(server: SrvT):
|
|
"""`server/discover` is a spec method (in `MONOLITH_REQUESTS`) but only at
|
|
2026-07-28; on a 2025 session it must be METHOD_NOT_FOUND even with a
|
|
registered handler."""
|
|
|
|
async def discover(ctx: Ctx, params: RequestParams) -> Any:
|
|
raise NotImplementedError # the version gate rejects the request first
|
|
|
|
server.add_request_handler("server/discover", RequestParams, discover)
|
|
async with connected_runner(server) as (client, runner):
|
|
assert runner.connection.protocol_version == "2025-11-25"
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("server/discover", None)
|
|
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="server/discover")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_middleware_short_circuit_on_a_wrong_version_spec_method_skips_the_sieve(server: SrvT):
|
|
"""A server-tier middleware that returns without calling `call_next` for a
|
|
spec method absent at the negotiated version owns the result shape; the
|
|
outbound sieve has no `(method, version)` row and must not raise."""
|
|
|
|
async def short_circuit(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
|
if method == "server/discover":
|
|
return {"ok": True}
|
|
return await call_next()
|
|
|
|
server.middleware.append(short_circuit)
|
|
async with connected_runner(server) as (client, runner):
|
|
assert runner.connection.protocol_version == "2025-11-25"
|
|
result = await client.send_raw_request("server/discover", None)
|
|
assert result == {"ok": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_custom_method_result_is_not_surface_validated(server: SrvT):
|
|
"""No `SERVER_RESULTS` row for a custom method, so its result reaches the client as-is."""
|
|
|
|
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
|
return {"anything": "goes"}
|
|
|
|
server.add_request_handler("custom/greet", RequestParams, custom)
|
|
async with connected_runner(server) as (client, _):
|
|
result = await client.send_raw_request("custom/greet", None)
|
|
assert result == {"anything": "goes"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_initialize_result_reflects_init_options():
|
|
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
|
raise NotImplementedError
|
|
|
|
server: SrvT = Server(name="caps-test", on_list_tools=list_tools, instructions="be nice")
|
|
init_options = server.create_initialization_options(NotificationOptions(tools_changed=True), {"ext": {"k": "v"}})
|
|
async with connected_runner(server, initialized=False, init_options=init_options) as (client, _):
|
|
result = await client.send_raw_request("initialize", _initialize_params())
|
|
assert result["capabilities"]["tools"]["listChanged"] is True
|
|
assert result["capabilities"]["experimental"] == {"ext": {"k": "v"}}
|
|
assert result["serverInfo"]["name"] == "caps-test"
|
|
assert result["instructions"] == "be nice"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_initialize_echoes_supported_version_and_falls_back_to_latest(server: SrvT):
|
|
oldest = SUPPORTED_PROTOCOL_VERSIONS[0]
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
params = {**_initialize_params(), "protocolVersion": oldest}
|
|
result = await client.send_raw_request("initialize", params)
|
|
assert result["protocolVersion"] == oldest
|
|
async with connected_runner(server, initialized=False) as (client, _):
|
|
params = {**_initialize_params(), "protocolVersion": "1999-01-01"}
|
|
result = await client.send_raw_request("initialize", params)
|
|
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_otel_middleware_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture):
|
|
async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
|
return {"content": [], "isError": False}
|
|
|
|
server.add_request_handler("tools/call", CallToolRequestParams, call_tool)
|
|
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
|
|
spans.clear()
|
|
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
|
|
assert result == {"content": [], "isError": False}
|
|
finished = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
|
[span] = finished
|
|
assert span.name == "MCP handle tools/call mytool"
|
|
assert span.attributes is not None
|
|
assert span.attributes["mcp.method.name"] == "tools/call"
|
|
assert isinstance(span.attributes["jsonrpc.request.id"], str)
|
|
assert span.status.status_code == StatusCode.UNSET
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_otel_trace_context_propagates_client_to_server(server: SrvT, spans: SpanCapture):
|
|
"""The client dispatcher injects traceparent into `_meta`; the server's
|
|
`otel_middleware` extracts it, so client and server spans share a trace."""
|
|
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
|
|
spans.clear()
|
|
await client.send_raw_request("tools/list", None)
|
|
[client_span] = [s for s in spans.finished() if s.kind == SpanKind.CLIENT]
|
|
[server_span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
|
assert server_span.parent is not None
|
|
assert client_span.context is not None and server_span.context is not None
|
|
assert server_span.parent.span_id == client_span.context.span_id
|
|
assert server_span.context.trace_id == client_span.context.trace_id
|
|
assert client_span.attributes is not None and server_span.attributes is not None
|
|
assert client_span.attributes["jsonrpc.request.id"] == server_span.attributes["jsonrpc.request.id"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_otel_middleware_malformed_traceparent_degrades_to_no_parent(server: SrvT, spans: SpanCapture):
|
|
"""A non-string traceparent in `_meta` must not fail the request; the
|
|
server span simply gets no parent."""
|
|
|
|
def break_traceparent(next_on_request: OnRequest) -> OnRequest:
|
|
async def wrapped(dctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]:
|
|
mangled = {"_meta": {"traceparent": 123}} if method == "tools/list" else params
|
|
return await next_on_request(dctx, method, mangled)
|
|
|
|
return wrapped
|
|
|
|
async with connected_runner(server, dispatch_middleware=[break_traceparent, otel_middleware]) as (client, _):
|
|
spans.clear()
|
|
await client.send_raw_request("tools/list", None)
|
|
[server_span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
|
assert server_span.parent is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_otel_middleware_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture):
|
|
"""Malformed params set the sanitized wire message as span status and do
|
|
not record the pydantic exception (it carries client input)."""
|
|
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
|
|
spans.clear()
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/call", {"name": 123})
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
|
assert span.status.status_code == StatusCode.ERROR
|
|
assert span.status.description == "Invalid request parameters"
|
|
assert not span.events
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_otel_middleware_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture):
|
|
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
|
|
spans.clear()
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("resources/list", None)
|
|
assert exc.value.error.code == METHOD_NOT_FOUND
|
|
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
|
assert span.status.status_code == StatusCode.ERROR
|
|
assert span.status.description == "Method not found"
|
|
# MCPError is a protocol-level response, not a crash - no traceback event.
|
|
assert not [e for e in span.events if e.name == "exception"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_otel_middleware_records_error_status_on_handler_exception(server: SrvT, spans: SpanCapture):
|
|
async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any:
|
|
raise ValueError("handler blew up")
|
|
|
|
server.add_request_handler("tools/list", PaginatedRequestParams, failing)
|
|
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
|
|
spans.clear()
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert exc.value.error.code == 0
|
|
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
|
assert span.status.status_code == StatusCode.ERROR
|
|
assert span.status.description == "handler blew up"
|
|
[event] = [e for e in span.events if e.name == "exception"]
|
|
assert event.attributes is not None
|
|
assert event.attributes["exception.type"] == "ValueError"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_connection_exit_stack_unwinds_after_run_returns(server: SrvT) -> None:
|
|
"""`runner.connection.exit_stack` is closed when the dispatcher loop ends."""
|
|
cleaned: list[int] = []
|
|
|
|
async def _append(i: int) -> None:
|
|
cleaned.append(i)
|
|
|
|
async with connected_runner(server) as (client, runner):
|
|
for i in (1, 2, 3):
|
|
runner.connection.exit_stack.push_async_callback(_append, i)
|
|
await client.send_raw_request("tools/list", None)
|
|
assert cleaned == []
|
|
assert cleaned == [3, 2, 1]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_exit_stack_cleanup_exception_is_logged_not_propagated(
|
|
server: SrvT, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A raising cleanup callback is caught and logged; `run()` exits cleanly."""
|
|
cleaned: list[str] = []
|
|
|
|
async def _ok() -> None:
|
|
cleaned.append("ok")
|
|
|
|
async def _boom() -> None:
|
|
raise RuntimeError("cleanup failed")
|
|
|
|
async with connected_runner(server) as (client, runner):
|
|
runner.connection.exit_stack.push_async_callback(_ok)
|
|
runner.connection.exit_stack.push_async_callback(_boom)
|
|
await client.send_raw_request("tools/list", None)
|
|
assert cleaned == ["ok"]
|
|
assert "connection exit_stack cleanup raised" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_exit_stack_blocking_cleanup_abandoned_after_grace(
|
|
server: SrvT, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A cleanup callback that never returns is abandoned once the grace period
|
|
elapses: `run()` exits, later callbacks in the unwind are cancelled at
|
|
their first checkpoint, and a warning is logged. Grace 0 means the deadline
|
|
is already expired on entry, so the abandonment is immediate."""
|
|
monkeypatch.setattr(mcp.server.runner, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
|
|
ran: list[str] = []
|
|
release = anyio.Event()
|
|
|
|
async def _abandoned() -> None:
|
|
# LIFO unwind: pushed first, so it runs after the blocker. By then the
|
|
# deadline has fired, so this checkpoint raises and the line below is
|
|
# unreachable (if abandonment broke, the missing warning fails the
|
|
# caplog assert).
|
|
await anyio.sleep(0)
|
|
raise NotImplementedError
|
|
|
|
async def _blocker() -> None:
|
|
ran.append("blocker started")
|
|
await release.wait()
|
|
raise NotImplementedError
|
|
|
|
async with connected_runner(server) as (client, runner):
|
|
runner.connection.exit_stack.push_async_callback(_abandoned)
|
|
runner.connection.exit_stack.push_async_callback(_blocker)
|
|
await client.send_raw_request("tools/list", None)
|
|
assert ran == ["blocker started"]
|
|
assert "abandoning remaining callbacks" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_runner_exit_stack_fast_cleanup_completes_within_grace(
|
|
server: SrvT, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Well-behaved cleanup callbacks run to completion under the bounded
|
|
unwind and no abandonment warning is logged. Uses the production grace;
|
|
the deadline never delays a fast unwind, it only bounds a hung one."""
|
|
cleaned: list[int] = []
|
|
|
|
async def _append(i: int) -> None:
|
|
await anyio.sleep(0)
|
|
cleaned.append(i)
|
|
|
|
async with connected_runner(server) as (client, runner):
|
|
for i in (1, 2):
|
|
runner.connection.exit_stack.push_async_callback(_append, i)
|
|
await client.send_raw_request("tools/list", None)
|
|
assert cleaned == [2, 1]
|
|
assert "abandoning remaining callbacks" not in caplog.text
|