Align with spec #3002: optional clientInfo, serverInfo in result _meta (#3143)

This commit is contained in:
Max 2026-07-23 12:00:36 +01:00 • committed by GitHub
commit 837ef904f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1757 additions and 567 deletions

View file

@ -2,17 +2,27 @@
These call the registered handler via the public `Server.get_request_handler`
accessor without spinning up a `ServerRunner` or any transport, so they verify
the handler's contract in isolation from the dispatch pipeline.
the handler's contract in isolation from the dispatch pipeline. The exception
is the server-identity pair: the serverInfo `_meta` stamp is applied by the
runner (spec 2026-07-28, #3002), not the handler, so those two drive one
request through `serve_one` to observe it.
"""
import importlib.metadata
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, cast
import anyio
import mcp_types as types
import pytest
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server import NotificationOptions, Server, ServerRequestContext
from mcp.server.connection import Connection
from mcp.server.runner import serve_one
from mcp.shared.dispatcher import CallOptions
from mcp.shared.message import MessageMetadata
from mcp.shared.transport_context import TransportContext
# `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are
@ -36,6 +46,47 @@ async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL
return result
@dataclass
class _StubDispatchContext:
"""Minimal `DispatchContext` for the `serve_one`-driven identity tests.
Satisfies the protocol structurally; the discover handler never touches
the back-channel.
"""
request_id: int | str | None = 1
transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
message_metadata: MessageMetadata = None
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = False
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
raise NotImplementedError
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:
raise NotImplementedError
async def _discover_over_runner(server: Server[Any]) -> dict[str, Any]:
"""Serve one `server/discover` through the runner - the layer that stamps
server identity into the result `_meta`."""
connection = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
params: dict[str, Any] = {
"_meta": {
types.PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
types.CLIENT_CAPABILITIES_META_KEY: {},
}
}
return await serve_one(
server, _StubDispatchContext(), "server/discover", params, connection=connection, lifespan_state={}
)
def test_registered_by_default() -> None:
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
the box, typed for the base `RequestParams`."""
@ -55,7 +106,8 @@ async def test_supported_versions_is_modern_set() -> None:
@pytest.mark.anyio
async def test_server_info_reflects_constructor_fields() -> None:
"""SDK-defined: `serverInfo` is built field-for-field from the `Server`
"""Server identity travels as the discover result's `_meta` serverInfo
stamp (spec 2026-07-28, #3002), built field-for-field from the `Server`
constructor arguments."""
icons = [types.Icon(src="https://example.test/icon.png")]
server = Server(
@ -66,8 +118,9 @@ async def test_server_info_reflects_constructor_fields() -> None:
website_url="https://example.test",
icons=icons,
)
result = await _discover(server)
assert result.server_info == types.Implementation(
result = await _discover_over_runner(server)
stamp = result["_meta"][types.SERVER_INFO_META_KEY]
assert types.Implementation.model_validate(stamp) == types.Implementation(
name="info-server",
version="9.9.9",
title="Info Server",
@ -78,11 +131,12 @@ async def test_server_info_reflects_constructor_fields() -> None:
@pytest.mark.anyio
async def test_server_info_version_falls_back_to_package() -> None:
"""SDK-defined: when no explicit version is supplied, `serverInfo.version`
falls back to the installed `mcp` package version."""
result = await _discover(Server("unversioned"))
assert result.server_info.version == importlib.metadata.version("mcp")
async def test_an_unversioned_server_reports_an_empty_version() -> None:
"""SDK-defined: when no explicit version is supplied, the stamped
`serverInfo` version is an empty string - the SDK never substitutes its
own package version for the server's."""
result = await _discover_over_runner(Server("unversioned"))
assert result["_meta"][types.SERVER_INFO_META_KEY] == {"name": "unversioned", "version": ""}
@pytest.mark.anyio
@ -143,7 +197,6 @@ async def test_overridable_via_add_request_handler() -> None:
custom = types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="custom-server", version="1.0.0"),
instructions="overridden",
ttl_ms=60_000,
cache_scope="public",

View file

@ -2,11 +2,11 @@
These exercise the closed set of extension contribution kinds - tools,
resources, request methods, and the single `tools/call` interceptor - through
the highest-level public surface (in-memory `Client`), plus the
`compose_tool_call_interceptor` helper directly.
the highest-level public surface (in-memory `Client`).
"""
from typing import Any, Literal, cast
from dataclasses import replace
from typing import Any, Literal
import mcp_types as types
import pytest
@ -14,6 +14,7 @@ from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
SERVER_INFO_META_KEY,
CallToolResult,
TextContent,
)
@ -26,7 +27,6 @@ from mcp.server.extension import (
MethodBinding,
ResourceBinding,
ToolBinding,
compose_tool_call_interceptor,
)
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from mcp.server.mcpserver.resources import TextResource
@ -150,10 +150,17 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None:
assert [t.name for t in tools.tools] == ["ping"]
assert tools.tools[0].meta == _TOOL_META
assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
assert called == snapshot(
CallToolResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="pong")],
structured_content={"result": "pong"},
)
)
assert resources == snapshot(
types.ListResourcesResult(
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")],
)
)
@ -195,7 +202,9 @@ async def test_extension_method_reachable_via_session_send_request() -> None:
request = _PingRequest(params=_PingParams())
result = await client.session.send_request(request, _PingResult)
assert result == snapshot(_PingResult(pong=True))
assert result == snapshot(
_PingResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, pong=True)
)
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
@ -207,7 +216,13 @@ async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
assert result == snapshot(
CallToolResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="hi")],
structured_content={"result": "hi"},
)
)
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
@ -219,26 +234,36 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
assert result == snapshot(
CallToolResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="intercepted")],
)
)
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
middleware - the composed interceptor exists only when at least one extension
overrides it."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
def test_plain_extension_leaves_the_tool_call_handler_bare() -> None:
"""SDK-defined: an extension that does not override `intercept_tool_call` leaves
`tools/call` registered as the server's own handler - the interceptor chain is
composed only when at least one extension overrides it."""
server = MCPServer("test", extensions=[_AdditiveExt()])
assert len(server._lowlevel_server.middleware) == baseline
entry = server._lowlevel_server.get_request_handler("tools/call")
assert entry is not None
assert entry.handler == server._handle_call_tool
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
one additional `tools/call` middleware."""
def test_overriding_extension_wraps_the_tool_call_handler() -> None:
"""SDK-defined: an extension that overrides `intercept_tool_call` re-registers
`tools/call` with the interceptor chain wrapped around the server's own handler,
and installs no middleware."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
server = MCPServer("test", extensions=[_ReplacingExt()])
assert len(server._lowlevel_server.middleware) == baseline + 1
entry = server._lowlevel_server.get_request_handler("tools/call")
assert entry is not None
assert entry.handler != server._handle_call_tool
assert len(server._lowlevel_server.middleware) == baseline
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
@ -251,11 +276,17 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one()
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
assert result == snapshot(
CallToolResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="hi")],
structured_content={"result": "hi"},
)
)
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
"""SDK-defined: `compose_tool_call_handler` nests extensions first-outermost, so
two passing-through interceptors record in registration order, each seeing the
validated `tools/call` params (the real tool name)."""
log: list[tuple[str, str]] = []
@ -271,26 +302,48 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() ->
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
"""SDK-defined: the composed middleware is a no-op for any method other than
`tools/call` - it forwards to `call_next` without touching the interceptors."""
sentinel = types.EmptyResult()
async def test_an_interceptor_context_rewrite_does_not_change_the_tool_invocation() -> None:
"""SDK-defined: the validated `params` argument is authoritative - an
interceptor passing a rewritten context through `call_next` adjusts what
the handler observes on `ctx`, not which tool call runs. Wire-level
request rewriting belongs to `Server.middleware`, above params validation."""
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
return sentinel
class _RewritingExt(Extension):
identifier = "com.example/rewriting"
middleware = compose_tool_call_interceptor([_ReplacingExt()])
ctx = ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version="2026-07-28",
method="tasks/get",
params={"taskId": "t-1"},
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
rewritten = {**(ctx.params or {}), "arguments": {"value": "rewritten"}}
return await call_next(replace(ctx, params=rewritten))
server = MCPServer("test", extensions=[_RewritingExt()])
server.tool(name="echo")(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "original"})
assert result == snapshot(
CallToolResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="original")],
structured_content={"result": "original"},
)
)
result = await middleware(ctx, call_next)
assert result is sentinel
async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None:
"""Spec-mandated (2026-07-28, #3002): an interceptor that answers without running
the tool still produces a stamped result - interception happens at the handler
layer, below the runner's outbound envelope pass."""
server = MCPServer("test", extensions=[_ReplacingExt()])
server.tool(name="echo", structured_output=False)(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result.content == [TextContent(text="intercepted")]
assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": ""}}
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
@ -345,7 +398,9 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
result = await client.session.send_request(request, _VersionPinnedResult)
assert result == snapshot(_VersionPinnedResult(ok=True))
assert result == snapshot(
_VersionPinnedResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, ok=True)
)
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
@ -425,7 +480,13 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
result = await client.call_tool("guarded", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
assert result == snapshot(
CallToolResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
content=[TextContent(text="ok")],
structured_content={"result": "ok"},
)
)
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:

View file

@ -998,7 +998,8 @@ class TestServerResourceTemplates:
result = await client.read_resource("resource://bob/csv")
assert result == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")]
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")],
)
)
@ -1065,6 +1066,7 @@ class TestServerResourceMetadata:
result = await client.read_resource("resource://data")
assert result == snapshot(
ReadResourceResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[
TextResourceContents(
uri="resource://data",
@ -1072,7 +1074,7 @@ class TestServerResourceMetadata:
meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType]
text="test data",
)
]
],
)
)
@ -1234,11 +1236,12 @@ class TestContextInjection:
result = await client.read_resource("resource://nocontext/test")
assert result == snapshot(
ReadResourceResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[
TextResourceContents(
uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works"
)
]
],
)
)
@ -1262,11 +1265,12 @@ class TestContextInjection:
result = await client.read_resource("resource://custom/123")
assert result == snapshot(
ReadResourceResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
contents=[
TextResourceContents(
uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context"
)
]
],
)
)
@ -1394,6 +1398,7 @@ class TestServerPrompts:
result = await client.list_prompts()
assert result == snapshot(
ListPromptsResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
prompts=[
Prompt(
name="fn",
@ -1403,7 +1408,7 @@ class TestServerPrompts:
PromptArgument(name="optional", required=False),
],
)
]
],
)
)
@ -1419,6 +1424,7 @@ class TestServerPrompts:
result = await client.get_prompt("fn", {"name": "World"})
assert result == snapshot(
GetPromptResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
description="",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))],
)
@ -1449,6 +1455,7 @@ class TestServerPrompts:
result = await client.get_prompt("fn", {"name": "World"})
assert result == snapshot(
GetPromptResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
description="This is the function docstring.",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))],
)
@ -1471,6 +1478,7 @@ class TestServerPrompts:
result = await client.get_prompt("fn")
assert result == snapshot(
GetPromptResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}},
description="",
messages=[
PromptMessage(

View file

@ -25,10 +25,12 @@ async def test_server_name_title_description_version():
# Start server and connect client
async with Client(mcp) as client:
assert client.server_info.name == "TestServer"
assert client.server_info.title == "Test Server Title"
assert client.server_info.description == "This is a test server description."
assert client.server_info.version == "1.0"
server_info = client.server_info
assert server_info is not None
assert server_info.name == "TestServer"
assert server_info.title == "Test Server Title"
assert server_info.description == "This is a test server description."
assert server_info.version == "1.0"
@pytest.mark.anyio

View file

@ -62,13 +62,14 @@ async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None:
result = await client.read_resource("ui://clock/app.html")
assert result == snapshot(
ReadResourceResult(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "clock", "version": ""}},
contents=[
TextResourceContents(
uri="ui://clock/app.html",
mime_type="text/html;profile=mcp-app",
text="<title>Clock</title>",
)
]
],
)
)
assert isinstance(result.contents[0], TextResourceContents)

View file

@ -133,19 +133,26 @@ async def test_a_dict_returning_handler_leaks_no_hint_fields_to_a_2025_session()
assert "cache_scope" not in result.model_fields_set
async def test_an_input_required_shaped_dict_is_never_stamped() -> None:
async def test_an_input_required_shaped_dict_never_gets_cache_hints() -> None:
"""Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method."""
async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]:
return {"resultType": "input_required", "requestState": "s1"}
server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")})
server = Server(
"srv",
cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")},
)
server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource)
async with Client(server) as client:
result = await client.session.read_resource("res://x", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
{"resultType": "input_required", "requestState": "s1"}
{
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "srv", "version": ""}},
"resultType": "input_required",
"requestState": "s1",
}
)

View file

@ -22,6 +22,7 @@ from mcp_types import (
ElicitationCapability,
EmptyResult,
Implementation,
InitializeRequestParams,
ListRootsRequest,
ListRootsResult,
PingRequest,
@ -321,8 +322,8 @@ async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_pa
# --- check_capability ----------------------------------------------------------
def test_connection_check_capability_false_when_no_client_params_recorded():
"""SDK-defined: `check_capability` returns False when no `client_params`
def test_connection_check_capability_false_when_no_capabilities_recorded():
"""SDK-defined: `check_capability` returns False when no capabilities
were recorded, regardless of which factory built the connection."""
conn = Connection.for_loop(StubOutbound())
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
@ -330,6 +331,33 @@ def test_connection_check_capability_false_when_no_client_params_recorded():
assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False
def test_from_envelope_records_capabilities_without_client_info():
"""Spec-mandated (spec PR #3002): the envelope requires capabilities but not
client info, so a pair-only request still gets working capability checks -
`client_capabilities` is recorded on its own while `client_params` stays
`None`."""
caps = ClientCapabilities(sampling=SamplingCapability())
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, caps)
assert conn.client_params is None
assert conn.client_capabilities == caps
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
def test_client_params_assignment_keeps_capabilities_in_lockstep():
"""SDK-defined: recording `client_params` (the loop path's handshake
commit) is the sync point that also records `client_capabilities`, so the
two facts cannot drift."""
conn = Connection.for_loop(StubOutbound())
assert conn.client_capabilities is None
conn.client_params = InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(roots=RootsCapability()),
client_info=Implementation(name="c", version="0"),
)
assert conn.client_capabilities == ClientCapabilities(roots=RootsCapability())
assert conn.check_capability(ClientCapabilities(roots=RootsCapability())) is True
@pytest.mark.parametrize(
("have", "want", "expected"),
[

View file

@ -25,9 +25,13 @@ from mcp_types import (
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolRequestParams,
ClientCapabilities,
EmptyResult,
ErrorData,
Icon,
Implementation,
InitializeRequestParams,
JSONRPCRequest,
@ -953,7 +957,14 @@ async def test_on_request_dispatches_custom_method_registered_via_add_request_ha
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}
# Custom-method results served at a modern version carry the required
# `resultType` discriminator and the serverInfo `_meta` stamp like any
# other result (spec 2026-07-28, #3002).
assert result == {
"echoed": True,
"resultType": "complete",
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
}
@pytest.mark.anyio
@ -987,6 +998,188 @@ async def test_runner_custom_method_result_is_not_surface_validated(server: SrvT
assert result == {"anything": "goes"}
@pytest.mark.anyio
async def test_modern_short_circuit_middleware_owns_its_result_envelope(server: SrvT):
"""SDK-defined: a middleware that answers without calling `call_next` is
trusted to return its own well-formed result, response envelope included -
the outbound pipeline (and its serverInfo stamp) never patches it up."""
async def short_circuit(ctx: Ctx, call_next: Any) -> Any:
return {"ok": True}
server.middleware.append(short_circuit)
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/anything", None)
assert result == {"ok": True}
@pytest.mark.anyio
async def test_a_handler_authored_server_info_stamp_is_not_overwritten(server: SrvT):
"""SDK-defined: a handler that stamps its own serverInfo `_meta` value owns
it; the runner fills the key only when it is absent (spec 2026-07-28, #3002)."""
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "authored", "version": "9"}}}
server.add_request_handler("myorg/authored", RequestParams, custom)
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/authored", None)
assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "authored", "version": "9"}
@pytest.mark.anyio
async def test_a_non_mapping_custom_result_meta_is_left_alone(server: SrvT):
"""SDK-defined: a custom-method handler that returns a non-mapping `_meta`
owns that shape; the stamp neither clobbers it nor fails the request."""
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"_meta": "not-a-mapping"}
server.add_request_handler("myorg/odd-meta", RequestParams, custom)
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/odd-meta", None)
assert result == {"_meta": "not-a-mapping", "resultType": "complete"}
@pytest.mark.anyio
async def test_stamping_never_mutates_a_handler_retained_result_dict(server: SrvT):
"""SDK-defined: the outbound pass dumps the handler's dict to a copy
(`_dump_result`) and the stamp writes into that copy, so a dict the handler
retains (module-level, cached, shared) is never mutated underneath it."""
retained: dict[str, Any] = {"ok": True}
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return retained
server.add_request_handler("myorg/cached", RequestParams, custom)
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/cached", None)
assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "test-server", "version": "0.0.1"}
assert retained == {"ok": True}
@pytest.mark.anyio
async def test_mutating_a_stamped_response_never_corrupts_later_stamps():
"""SDK-defined: every response gets a fresh stamp dict, nested values
included - a caller mutating one stamped response (a middleware, or an
application holding an in-memory result) cannot corrupt the identity
stamped into later responses."""
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {}
server: SrvT = Server(name="test-server", version="0.0.1", icons=[Icon(src="https://example.com/icon.png")])
server.add_request_handler("myorg/empty", RequestParams, custom)
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
first = await client.send_raw_request("myorg/empty", None)
first["_meta"][SERVER_INFO_META_KEY]["icons"][0]["src"] = "https://evil.example/pwned.png"
second = await client.send_raw_request("myorg/empty", None)
assert second["_meta"][SERVER_INFO_META_KEY] == {
"name": "test-server",
"version": "0.0.1",
"icons": [{"src": "https://example.com/icon.png"}],
}
@pytest.mark.anyio
async def test_a_claimed_result_type_on_a_legacy_session_is_sieved_not_leaked(server: SrvT):
"""SDK-defined: claimed extension shapes are 2026-era vocabulary, so on a
handshake-era session the per-version sieve still applies - a claimed
shape (not a valid legacy result) surfaces as INTERNAL_ERROR instead of
leaking a shape the client cannot resolve."""
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"resultType": "voucher", "voucherCode": "v-42"}
server.add_request_handler("tools/call", CallToolRequestParams, custom)
async with connected_runner(server) as (client, _):
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/call", {"name": "issue"})
assert exc.value.error.code == INTERNAL_ERROR
assert exc.value.error.message == "Handler returned an invalid result"
@pytest.mark.anyio
async def test_a_handshake_era_custom_result_gains_no_discriminator(server: SrvT):
"""The `resultType` fill is modern-only: handshake-era results keep the
handler's exact shape (the field does not exist pre-2026)."""
async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"echoed": True}
server.add_request_handler("myorg/echo", RequestParams, echo)
async with connected_runner(server) as (client, _):
result = await client.send_raw_request("myorg/echo", None)
assert result == {"echoed": True}
@pytest.mark.anyio
async def test_an_explicit_null_result_type_is_filled_on_the_modern_path(server: SrvT):
"""A handler-authored `"resultType": null` reads as absent (null is not a
valid discriminator) and is filled, mirroring the null posture of the
identity stamp."""
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"echoed": True, "resultType": None}
server.add_request_handler("myorg/echo", RequestParams, custom)
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,
"resultType": "complete",
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
}
@pytest.mark.anyio
async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT):
"""SDK-defined: a spec-method result carrying an extension `resultType` is a
claimed shape the extension owns - the per-version sieve would strip its
vendor fields, so it applies to core-vocabulary results only. The identity
stamp still lands: claimed shapes are results like any other."""
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"resultType": "voucher", "voucherCode": "v-42"}
server.add_request_handler("tools/call", CallToolRequestParams, custom)
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("tools/call", _modern_params(name="issue"))
assert result == {
"resultType": "voucher",
"voucherCode": "v-42",
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
}
@pytest.mark.anyio
async def test_an_empty_result_on_the_modern_path_carries_the_discriminator_and_stamp(server: SrvT):
"""Spec-mandated (2026-07-28): `resultType` is required on every result a
modern server sends (the absent-means-complete bridge is for clients of
older servers only), and serverInfo is stamped into every result too - so
a result that dumps as `{}` goes on the modern wire with both."""
async def custom(ctx: Ctx, params: RequestParams) -> EmptyResult:
return EmptyResult()
server.add_request_handler("myorg/empty", RequestParams, custom)
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/empty", None)
assert result == {
"resultType": "complete",
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
}
@pytest.mark.anyio
async def test_runner_initialize_result_reflects_init_options():
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
@ -1447,6 +1640,44 @@ async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT
assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_dual_era_loop_pair_only_envelope_serves_modern_and_locks(server: SrvT):
"""Spec-mandated (spec PR #3002): the required envelope pair (protocol version
+ client capabilities) without the optional clientInfo is a complete
modern request - it is served, records the declared capabilities without
client params, locks the era modern, and a later legacy `initialize` is
rejected with -32022."""
params = _modern_params()
del params["_meta"][CLIENT_INFO_META_KEY]
async with dual_era_client(server) as (client, _):
result = await client.send_raw_request("tools/list", params)
assert result["tools"][0]["name"] == "t"
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("initialize", _initialize_params())
assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
ctx = _seen_ctx[-1]
assert ctx.session.client_params is None
assert ctx.session.client_capabilities == ClientCapabilities()
@pytest.mark.anyio
async def test_dual_era_loop_version_without_capabilities_rejects_naming_the_key_and_never_locks(server: SrvT):
"""A `_meta` declaring the protocol version but missing the required
client-capabilities key routes modern - never the legacy path with its
generic 'Invalid request parameters' - and is rejected INVALID_PARAMS
naming the missing key; like every failed classification it locks no era,
so the legacy handshake stays available."""
params = _modern_params()
del params["_meta"][CLIENT_CAPABILITIES_META_KEY]
async with dual_era_client(server) as (client, _):
with pytest.raises(MCPError) as exc_info:
await client.send_raw_request("tools/list", params)
init = await client.send_raw_request("initialize", _initialize_params())
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
assert exc_info.value.error.code == INVALID_PARAMS
assert CLIENT_CAPABILITIES_META_KEY in exc_info.value.error.message
@pytest.mark.anyio
async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT):
"""A well-formed modern request for an unknown method fails without
@ -1688,17 +1919,27 @@ async def test_dual_era_loop_custom_method_with_mis_shaped_envelope_values_still
params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
async with dual_era_client(greeter) as (client, _):
result = await client.send_raw_request("custom/greet", params)
assert result == {"ok": True}
assert result == {
"ok": True,
"resultType": "complete",
"_meta": {SERVER_INFO_META_KEY: {"name": "greeter-server", "version": "0.0.1"}},
}
assert seen == [None]
def test_has_modern_envelope_requires_the_full_key_triple():
def test_has_modern_envelope_keys_on_the_protocol_version_key():
"""Era evidence is the reserved protocol-version `_meta` key: legacy traffic
never mints it (bare `_meta` / `progressToken` is not evidence), and a
half-built envelope still routes modern so the classifier - not the legacy
path - names its missing required key."""
assert not _has_modern_envelope(None)
assert not _has_modern_envelope({})
assert not _has_modern_envelope({"_meta": None})
assert not _has_modern_envelope({"_meta": {"progressToken": 1}})
partial_meta = {k: v for k, v in _modern_envelope().items() if k != CLIENT_CAPABILITIES_META_KEY}
assert not _has_modern_envelope({"_meta": partial_meta})
version_only = {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}
assert _has_modern_envelope({"_meta": version_only})
pair_only = {k: v for k, v in _modern_envelope().items() if k != CLIENT_INFO_META_KEY}
assert _has_modern_envelope({"_meta": pair_only})
assert _has_modern_envelope(_modern_params())

View file

@ -11,6 +11,7 @@ from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
@ -264,11 +265,14 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope})
tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope})
responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2)
server = MCPServer(name="ModernStdioServer", version="1.2.3")
responses = _serve_stdio_and_collect(monkeypatch, server, [discover, tools], 2)
assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1
assert "2026-07-28" in responses[0].result["supportedVersions"]
assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer"
# Server identity travels as the result `_meta` stamp, not a DiscoverResult
# body field (spec 2026-07-28, #3002).
assert responses[0].result["_meta"][SERVER_INFO_META_KEY] == {"name": "ModernStdioServer", "version": "1.2.3"}
assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2
# `resultType` is the modern-only wire field: its presence proves the
# request was served at the discovered version, not the handshake era.

View file

@ -24,8 +24,10 @@ from mcp_types import (
METHOD_NOT_FOUND,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
CallToolRequestParams,
CallToolResult,
ClientCapabilities,
ErrorData,
JSONRPCError,
JSONRPCResponse,
@ -158,6 +160,40 @@ def _list_tools_body() -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
async def test_handle_modern_request_serves_pair_only_envelope_without_client_info() -> None:
"""Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose
`_meta` carries only the protocol-version + client-capabilities pair is
served, with the declared capabilities recorded and `client_params` None."""
seen: list[tuple[object, object]] = []
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
seen.append((ctx.session.client_params, ctx.session.client_capabilities))
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=list_tools)
body = _list_tools_body()
del body["params"]["_meta"][CLIENT_INFO_META_KEY]
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
assert response.json()["result"]["tools"] == []
assert seen == [(None, ClientCapabilities())]
async def test_handle_modern_request_missing_capabilities_rejects_naming_the_key() -> None:
"""Spec-mandated (basic/index.mdx): the protocol version without the
required client-capabilities key is malformed - INVALID_PARAMS (HTTP 400)
with a message naming the missing key."""
body = _list_tools_body()
del body["params"]["_meta"][CLIENT_CAPABILITIES_META_KEY]
async with _asgi_client(Server("test")) as http:
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 400
error = response.json()["error"]
assert error["code"] == INVALID_PARAMS
assert CLIENT_CAPABILITIES_META_KEY in error["message"]
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
the request still routes (200 + result) and the handler observes ``client_params is None``
@ -178,7 +214,12 @@ async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"})
assert response.status_code == 200
assert response.json()["result"] == {"ok": True}
result = response.json()["result"]
assert result == {
"_meta": {SERVER_INFO_META_KEY: {"name": "test", "version": ""}},
"ok": True,
"resultType": "complete",
}
assert seen == [None]
@ -709,6 +750,20 @@ async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None:
assert response.json()["result"]["content"] == []
async def test_modern_tools_call_validates_mcp_param_headers_for_a_pair_only_envelope() -> None:
"""The schema-resolving `tools/list` walk builds its synthetic envelope from the caller's:
a pair-only caller (spec PR #3002, no clientInfo) omits the optional key rather than sending
null, so header validation still runs and a mismatched header is still rejected."""
body = _tool_call_body({"region": "east"})
del body["params"]["_meta"][CLIENT_INFO_META_KEY]
async with _asgi_client(_x_mcp_server()) as http:
matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "east"})
mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "west"})
assert matched.status_code == 200
assert mismatched.status_code == 400
assert mismatched.json()["error"]["code"] == HEADER_MISMATCH
@pytest.mark.parametrize("json_response", [True, False])
async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch(
json_response: bool,
@ -1063,7 +1118,7 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
the SSE path, acks first, and ends with the stamped result on close()."""
bus = _OpenSignalBus()
handler = ListenHandler(bus)
server = Server("test", on_subscriptions_listen=handler)
server = Server("test", version="1.2.3", on_subscriptions_listen=handler)
body = _listen_body()
responses: list[httpx2.Response] = []
@ -1086,4 +1141,9 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
events = _sse_payloads(response.text)
assert events[0]["method"] == "notifications/subscriptions/acknowledged"
assert events[1]["id"] == 9
assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9}
# The terminal listen result is a modern-era result like any other, so it
# carries the serverInfo stamp alongside the subscription id.
assert events[1]["result"]["_meta"] == {
"io.modelcontextprotocol/subscriptionId": 9,
SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"},
}