Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)

This commit is contained in:
Max 2026-06-25 16:09:23 +02:00 • committed by GitHub
commit f226d00d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -4,7 +4,7 @@ The kernel tests run 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. Driver tests (`serve_connection`, `serve_one`,
`to_jsonrpc_response`, `aclose_shielded`) follow at the bottom.
`aclose_shielded`) follow at the bottom.
"""
from collections.abc import AsyncIterator, Mapping
@ -30,7 +30,6 @@ from mcp.server.runner import (
otel_middleware,
serve_connection,
serve_one,
to_jsonrpc_response,
)
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest
@ -39,7 +38,7 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import MessageMetadata
from mcp.shared.peer import dump_params
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
@ -50,9 +49,6 @@ from mcp.types import (
ErrorData,
Implementation,
InitializeRequestParams,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
NotificationParams,
PaginatedRequestParams,
@ -71,7 +67,7 @@ Ctx = ServerRequestContext[dict[str, Any], Any]
def _initialize_params() -> dict[str, Any]:
return InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="1.0"),
).model_dump(by_alias=True, exclude_none=True)
@ -168,7 +164,7 @@ async def test_runner_handles_initialize_and_populates_connection(server: SrvT):
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.protocol_version == LATEST_HANDSHAKE_VERSION
assert runner.connection.initialize_accepted is True
@ -245,7 +241,7 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
assert isinstance(ctx.session, ServerSession)
assert ctx.session.protocol_version == runner.connection.protocol_version
assert ctx.request_id is not None
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
assert ctx.protocol_version == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
@ -290,7 +286,7 @@ 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,
"protocol_version": LATEST_HANDSHAKE_VERSION,
"capabilities": {},
"client_info": {"name": "c", "version": "0"},
}
@ -815,7 +811,7 @@ async def test_runner_with_born_ready_connection_skips_init_gate(server: SrvT):
"""A `Connection.from_envelope` connection is born ready: the kernel's
init-gate is open without any handshake. The kernel is mode-agnostic - the
same `on_request` reads `connection.initialize_accepted` as a fact."""
born_ready = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
born_ready = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.initialize_accepted is True
assert runner.connection.initialized.is_set()
@ -848,7 +844,7 @@ async def test_server_add_request_handler_routes_custom_method_with_validated_pa
@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
assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/call", {"name": 42})
assert exc.value.error.code == INVALID_PARAMS
@ -927,9 +923,9 @@ async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not
async def test_on_request_rejects_initialize_at_modern_version_with_method_not_found(server: SrvT):
"""Spec-mandated: `initialize` has no `CLIENT_REQUESTS` row at the modern
version; kernel dispatch (not the inbound classifier) rejects it."""
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.protocol_version == MODERN_PROTOCOL_VERSIONS[0]
assert runner.connection.protocol_version == LATEST_MODERN_VERSION
with pytest.raises(MCPError) as exc:
await client.send_raw_request("initialize", _initialize_params())
assert exc.value.error.code == METHOD_NOT_FOUND
@ -944,7 +940,7 @@ async def test_on_request_dispatches_custom_method_registered_via_add_request_ha
return {"echoed": True}
server.add_request_handler("myorg/echo", RequestParams, echo)
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
result = await client.send_raw_request("myorg/echo", None)
assert result == {"echoed": True}
@ -998,7 +994,7 @@ async def test_runner_initialize_result_reflects_init_options():
@pytest.mark.anyio
async def test_runner_initialize_echoes_supported_version_and_falls_back_to_latest(server: SrvT):
oldest = SUPPORTED_PROTOCOL_VERSIONS[0]
oldest = OLDEST_SUPPORTED_VERSION
async with connected_runner(server, initialized=False) as (client, _):
params = {**_initialize_params(), "protocolVersion": oldest}
result = await client.send_raw_request("initialize", params)
@ -1006,7 +1002,7 @@ async def test_runner_initialize_echoes_supported_version_and_falls_back_to_late
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
assert result["protocolVersion"] == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
@ -1202,71 +1198,6 @@ async def test_runner_exit_stack_fast_cleanup_completes_within_grace(
assert "abandoning remaining callbacks" not in caplog.text
# --- to_jsonrpc_response -------------------------------------------------------
@pytest.mark.anyio
async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response():
"""SDK-defined: a handler coroutine resolving to a result dict is wrapped as a
`JSONRPCResponse` carrying the supplied id and the dict verbatim as `result`."""
async def ok() -> dict[str, Any]:
return {"k": "v"}
reply = await to_jsonrpc_response(7, ok())
assert isinstance(reply, JSONRPCResponse)
assert reply.id == 7
assert reply.result == {"k": "v"}
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error():
"""SDK-defined: an `MCPError` raised by the handler coroutine is wrapped as a
`JSONRPCError` whose `error` carries the same code, message, and data."""
async def fail() -> dict[str, Any]:
raise MCPError(code=METHOD_NOT_FOUND, message="nope", data="x")
reply = await to_jsonrpc_response("rid", fail())
assert isinstance(reply, JSONRPCError)
assert reply.id == "rid"
assert reply.error == ErrorData(code=METHOD_NOT_FOUND, message="nope", data="x")
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params():
"""SDK-defined: a pydantic `ValidationError` escaping the handler coroutine is
mapped to `INVALID_PARAMS` with a generic message (validator detail does not
reach the wire)."""
async def fail() -> dict[str, Any]:
Tool.model_validate({"name": 123}) # raises ValidationError
raise NotImplementedError
reply = await to_jsonrpc_response(1, fail())
assert isinstance(reply, JSONRPCError)
assert reply.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: an unmapped exception is logged server-side and surfaced as
`INTERNAL_ERROR` with a generic message; the exception text never reaches the
wire."""
async def fail() -> dict[str, Any]:
raise RuntimeError("boom")
reply = await to_jsonrpc_response(1, fail())
assert isinstance(reply, JSONRPCError)
assert reply.error.code == INTERNAL_ERROR
# Handler internals never reach the wire.
assert "boom" not in reply.error.message
assert "request handler raised" in caplog.text
# --- aclose_shielded -----------------------------------------------------------
@ -1310,7 +1241,7 @@ class _StubDispatchContext:
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
raise NotImplementedError
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
@ -1325,34 +1256,34 @@ _LIFESPAN: dict[str, Any] = {}
@pytest.mark.anyio
async def test_serve_one_runs_handler_and_returns_jsonrpc_response(server: SrvT):
async def test_serve_one_runs_handler_and_returns_result_dict(server: SrvT):
"""The single-exchange driver: builds the kernel, runs `on_request` once,
wraps via `to_jsonrpc_response`, and tears down `connection.exit_stack`."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
returns the agnostic result dict, and tears down `connection.exit_stack`."""
conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
request = JSONRPCRequest(jsonrpc="2.0", id=9, method="tools/list", params=None)
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(9), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCResponse)
assert reply.id == 9
assert reply.result["tools"][0]["name"] == "t"
result = await serve_one(
server, _StubDispatchContext(9), "tools/list", None, connection=conn, lifespan_state=_LIFESPAN
)
assert result["tools"][0]["name"] == "t"
assert cleaned == [1]
ctx = _seen_ctx[0]
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
assert ctx.protocol_version == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
async def test_serve_one_maps_error_to_jsonrpc_error_and_still_closes_exit_stack(server: SrvT):
async def test_serve_one_propagates_error_and_still_closes_exit_stack(server: SrvT):
"""SDK-defined: a kernel-produced error (here `METHOD_NOT_FOUND` for an
unregistered method) is wrapped as a `JSONRPCError`, and the per-request
exit stack is closed on the error path too."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
unregistered method) propagates as `MCPError`, and the per-request exit
stack is closed on the error path too."""
conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
request = JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/list", params=None)
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(2), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCError)
assert reply.error.code == METHOD_NOT_FOUND
with pytest.raises(MCPError) as exc_info:
await serve_one(
server, _StubDispatchContext(2), "resources/list", None, connection=conn, lifespan_state=_LIFESPAN
)
assert exc_info.value.error.code == METHOD_NOT_FOUND
assert cleaned == [1]
@ -1361,11 +1292,17 @@ async def test_serve_one_reads_connection_protocol_version_as_a_fact(server: Srv
"""`serve_one` builds the kernel over the entry's `Connection`; the kernel
reads `connection.protocol_version` for the version gate. A `from_envelope`
connection at a modern version rejects a method absent there."""
conn = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "info"})
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(1), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCError)
assert reply.error.code == METHOD_NOT_FOUND
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
with pytest.raises(MCPError) as exc_info:
await serve_one(
server,
_StubDispatchContext(1),
"logging/setLevel",
{"level": "info"},
connection=conn,
lifespan_state=_LIFESPAN,
)
assert exc_info.value.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
@ -1389,5 +1326,5 @@ async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: Sr
assert cleaned == []
close()
assert cleaned == [1]
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
assert conn.client_params is not None