Protocol types for 2026-07-28: superset monolith, committed per-version packages, and wire-method maps (#2849)

This commit is contained in:
Max 2026-06-16 17:40:14 +01:00 • committed by GitHub
commit 65be5a7147
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 19076 additions and 371 deletions

View file

@ -111,7 +111,9 @@ async def test_list_tools_with_lowlevel_server():
) -> ListToolsResult:
# Echo back what cursor we received in the tool description
cursor = params.cursor if params else None
return ListToolsResult(tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={})])
return ListToolsResult(
tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={"type": "object"})]
)
server = Server("test-lowlevel", on_list_tools=handle_list_tools)

View file

@ -2,13 +2,13 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Any
from typing import Any, cast
import anyio
import anyio.abc
import anyio.streams.memory
import pytest
from pydantic import FileUrl
from pydantic import FileUrl, ValidationError
from mcp import MCPError, types
from mcp.client import ClientRequestContext
@ -21,6 +21,7 @@ from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
CONNECTION_CLOSED,
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
@ -651,7 +652,7 @@ async def test_client_tool_call_with_meta(meta: RequestParamsMeta | None):
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
mocked_tool = types.Tool(name="sample_tool", input_schema={})
mocked_tool = types.Tool(name="sample_tool", input_schema={"type": "object"})
async def mock_server():
# Receive initialization request from client
@ -781,6 +782,168 @@ async def test_receive_loop_drops_unknown_notification_method_without_response()
assert out.message.id == 1
def _set_negotiated_version(session: ClientSession, version: str) -> None:
"""Force `session.protocol_version` without running the handshake."""
session._initialize_result = InitializeResult(
protocol_version=version,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@pytest.mark.anyio
async def test_on_request_rejects_a_server_request_absent_at_the_negotiated_version():
"""`elicitation/create` does not exist at 2025-03-26: the version gate answers
METHOD_NOT_FOUND instead of reaching the elicitation callback."""
async with raw_client_session() as (session, to_client, from_client):
_set_negotiated_version(session, "2025-03-26")
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="elicitation/create", params={"message": "hi"}))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.error.code == METHOD_NOT_FOUND
assert out.message.error.data == "elicitation/create"
@pytest.mark.anyio
async def test_on_request_validates_the_callback_result_against_the_surface_schema():
"""A surface-valid callback result reaches the wire as the dump dict unchanged."""
async def sampling(
ctx: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
return types.CreateMessageResult(role="assistant", content=types.TextContent(type="text", text="hi"), model="m")
request_params = types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="q"))],
max_tokens=10,
).model_dump(by_alias=True, mode="json", exclude_none=True)
async with raw_client_session(sampling_callback=sampling) as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="sampling/createMessage", params=request_params))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCResponse)
assert out.message.result == {"role": "assistant", "content": {"type": "text", "text": "hi"}, "model": "m"}
@pytest.mark.anyio
async def test_on_request_callback_returning_a_surface_invalid_result_is_internal_error(
caplog: pytest.LogCaptureFixture,
):
"""A callback result the surface schema rejects is answered with INTERNAL_ERROR.
`EmptyResult` is a `ClientResult` arm so the union accepts it, but `roots/list`
requires a `roots` array."""
async def list_roots(ctx: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
return cast("types.ListRootsResult", types.EmptyResult())
async with raw_client_session(list_roots_callback=list_roots) as (_session, to_client, from_client):
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=3, method="roots/list")))
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.error.code == INTERNAL_ERROR
assert out.message.error.message == "Client callback returned an invalid result"
assert "client callback for 'roots/list' returned an invalid result" in caplog.text
@pytest.mark.anyio
async def test_on_notify_drops_a_server_notification_absent_at_the_negotiated_version(
caplog: pytest.LogCaptureFixture,
):
"""`notifications/elicitation/complete` does not exist at 2025-06-18: it is
debug-log-dropped without reaching `message_handler`."""
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
with caplog.at_level("DEBUG", logger="client"):
async with raw_client_session(message_handler=handler) as (session, to_client, _):
_set_negotiated_version(session, "2025-06-18")
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/elicitation/complete", params={"elicitationId": "e1"}
)
)
)
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert len(seen) == 1
assert isinstance(seen[0], types.ToolListChangedNotification)
assert "dropped 'notifications/elicitation/complete': not defined at 2025-06-18" in caplog.text
@pytest.mark.anyio
async def test_on_request_elicitation_with_loose_property_schema_reaches_the_callback():
"""Older python-sdk servers emit `anyOf` for `Optional` form fields; the
inbound surface gate must let that through to the elicitation callback."""
seen: list[types.ElicitRequestParams] = []
async def elicitation(ctx: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
seen.append(params)
return types.ElicitResult(action="accept", content={"x": 1})
request_params = {
"message": "m",
"requestedSchema": {
"type": "object",
"properties": {"x": {"anyOf": [{"type": "integer"}, {"type": "null"}]}},
},
}
async with raw_client_session(elicitation_callback=elicitation) as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=4, method="elicitation/create", params=request_params))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCResponse)
assert out.message.result == {"action": "accept", "content": {"x": 1}}
assert len(seen) == 1
@pytest.mark.anyio
async def test_send_request_validates_the_server_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
async with raw_client_session() as (session, to_client, from_client):
async with anyio.create_task_group() as tg:
async def call() -> None:
with pytest.raises(ValidationError):
await session.send_request(types.ListToolsRequest(), types.EmptyResult)
tg.start_soon(call)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
await to_client.send(
SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"tools": "nope"}))
)
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
`result_type` validates."""
async with raw_client_session() as (session, to_client, from_client):
_set_negotiated_version(session, "2026-07-28")
async with anyio.create_task_group() as tg:
async def call() -> None:
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)
tg.start_soon(call)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
await to_client.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={})))
@pytest.mark.anyio
async def test_raising_sampling_callback_answers_with_code_zero():
"""A raising sampling callback is answered with code 0 and `str(exc)` (SDK-defined).
@ -804,8 +967,8 @@ async def test_raising_sampling_callback_answers_with_code_zero():
@pytest.mark.anyio
async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest.LogCaptureFixture):
"""A malformed notification is logged and dropped without reaching `message_handler` (SDK-defined).
Scripted peer: the typed API cannot emit a method outside the spec's notification union."""
"""A malformed notification is warn-logged and dropped without reaching `message_handler` (SDK-defined).
Scripted peer: the typed API cannot emit malformed params for a spec method."""
seen: list[object] = []
delivered = anyio.Event()
@ -814,14 +977,16 @@ async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest
delivered.set()
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
await to_client.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="not/a/spec/notification")))
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params={"broken": 1}))
)
# Follow with a valid notification so we know the loop is still alive.
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert isinstance(seen[0], types.ToolListChangedNotification)
assert "Failed to validate notification" in caplog.text
assert "Failed to validate notification: notifications/progress" in caplog.text
@pytest.mark.anyio

View file

@ -1338,7 +1338,10 @@ REQUIREMENTS: dict[str, Requirement] = {
note=(
"ServerSession.elicit_form forwards an arbitrary dict[str, Any] schema unchanged; no shape "
"validation at the low-level session layer (the high-level Context.elicit / "
"elicit_with_validation helper enforces primitive-only fields before generating the schema)."
"elicit_with_validation helper enforces primitive-only fields before generating the schema). "
"ClientSession likewise does not enforce it: the inbound surface gate is relaxed for "
"requestedSchema.properties so older servers that emit anyOf for Optional fields still reach "
"the elicitation callback."
),
),
),

View file

@ -415,6 +415,7 @@ async def test_elicit_form_schema_with_every_primitive_and_enum_type_reaches_the
"subscribe": {"type": "boolean", "default": False},
"tier": {"type": "string", "enum": ["free", "pro", "team"]},
"region": {
"type": "string",
"oneOf": [
{"const": "eu", "title": "Europe"},
{"const": "na", "title": "North America"},
@ -459,7 +460,8 @@ async def test_elicit_form_with_a_nested_schema_is_forwarded_unchanged(connect:
The spec restricts form-mode requested schemas to flat objects with primitive-typed properties;
this test pins that the SDK does not enforce that restriction on either side (see the
divergence on the requirement).
divergence on the requirement). The inbound surface gate is deliberately relaxed here so older
servers that emit `anyOf` for `Optional` form fields still reach the elicitation callback.
"""
schema: ElicitRequestedSchema = {
"type": "object",

View file

@ -81,7 +81,9 @@ async def test_list_resources_returns_registered_resources(connect: Connect) ->
description="The project's front page.",
mime_type="text/markdown",
size=1024,
annotations=Annotations(audience=["user", "assistant"], priority=0.8),
annotations=Annotations(
audience=["user", "assistant"], priority=0.8, last_modified="2025-01-01T00:00:00Z"
),
icons=[Icon(src="https://example.com/readme.png", mime_type="image/png", sizes=["48x48"])],
),
]

View file

@ -1,6 +1,6 @@
"""Test the elicitation feature over the in-memory client transport."""
from typing import Any
from typing import Any, Literal
import pytest
from pydantic import BaseModel, Field
@ -175,6 +175,14 @@ async def test_elicitation_with_optional_fields():
for content, expected in test_cases:
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
assert isinstance(params, types.ElicitRequestFormParams)
# Optional fields render as the bare primitive (no anyOf), absent from `required`.
assert params.requested_schema["properties"]["optional_age"] == {
"type": "integer",
"title": "Optional Age",
"description": "Your age (optional)",
}
assert params.requested_schema["required"] == ["required_name"]
return ElicitResult(action="accept", content=content)
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
@ -203,45 +211,32 @@ async def test_elicitation_with_optional_fields():
text_contains=["Validation failed:", "optional_list"],
)
# Test valid list[str] for multi-select enum
class ValidMultiSelectSchema(BaseModel):
# Bare `list[str]` renders without enum items and so is not a spec MultiSelectEnumSchema.
class BareListSchema(BaseModel):
name: str = Field(description="Name")
tags: list[str] = Field(description="Tags")
@mcp.tool(description="Tool with valid list[str] field")
async def valid_multiselect_tool(ctx: Context) -> str:
result = await ctx.elicit(message="Please provide tags", schema=ValidMultiSelectSchema)
if result.action == "accept" and result.data:
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
return f"User {result.action}" # pragma: no cover
async def multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "Please provide tags" in params.message:
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
return ElicitResult(action="decline") # pragma: no cover
await call_tool_and_assert(mcp, multiselect_callback, "valid_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2")
# Test Optional[list[str]] for optional multi-select enum
class OptionalMultiSelectSchema(BaseModel):
name: str = Field(description="Name")
tags: list[str] | None = Field(default=None, description="Optional tags")
@mcp.tool(description="Tool with optional list[str] field")
async def optional_multiselect_tool(ctx: Context) -> str:
result = await ctx.elicit(message="Please provide optional tags", schema=OptionalMultiSelectSchema)
if result.action == "accept" and result.data:
tags_str = ", ".join(result.data.tags) if result.data.tags else "none"
return f"Name: {result.data.name}, Tags: {tags_str}"
return f"User {result.action}" # pragma: no cover
async def optional_multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "Please provide optional tags" in params.message:
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
return ElicitResult(action="decline") # pragma: no cover
def make_reject_tool(tool_name: str, schema_cls: type[BaseModel]) -> None:
@mcp.tool(name=tool_name, description="Tool with a rejected field")
async def _tool(ctx: Context) -> str:
try:
await ctx.elicit(message="Provide value", schema=schema_cls)
except TypeError as e:
return f"Validation failed: {str(e)}"
raise NotImplementedError
make_reject_tool("bare_list_tool", BareListSchema)
await call_tool_and_assert(
mcp, optional_multiselect_callback, "optional_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2"
mcp, elicitation_callback, "bare_list_tool", {}, text_contains=["Validation failed:", "tags"]
)
# A union of two primitives renders as `anyOf`, outside `PrimitiveSchemaDefinition`.
class MultiPrimitiveSchema(BaseModel):
value: int | str = Field(description="Value")
make_reject_tool("multi_primitive_tool", MultiPrimitiveSchema)
await call_tool_and_assert(
mcp, elicitation_callback, "multi_primitive_tool", {}, text_contains=["Validation failed:", "value"]
)
@ -328,7 +323,22 @@ async def test_elicitation_with_enum_titles():
return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}"
return f"User {result.action}" # pragma: no cover
# Test multi-select with titles using anyOf
# Test legacy enumNames format
class LegacyColorSchema(BaseModel):
user_name: str = Field(description="Your name")
color: str = Field(
description="Select a color",
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
)
@mcp.tool(description="Legacy enum format")
async def select_color_legacy(ctx: Context) -> str:
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
if result.action == "accept" and result.data:
return f"User: {result.data.user_name}, Color: {result.data.color}"
return f"User {result.action}" # pragma: no cover
# Test multi-select with titles using items.anyOf
class FavoriteColorsSchema(BaseModel):
user_name: str = Field(description="Your name")
favorite_colors: list[str] = Field(
@ -350,32 +360,14 @@ async def test_elicitation_with_enum_titles():
result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema)
if result.action == "accept" and result.data:
return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}"
return f"User {result.action}" # pragma: no cover
# Test legacy enumNames format
class LegacyColorSchema(BaseModel):
user_name: str = Field(description="Your name")
color: str = Field(
description="Select a color",
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
)
@mcp.tool(description="Legacy enum format")
async def select_color_legacy(ctx: Context) -> str:
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
if result.action == "accept" and result.data:
return f"User: {result.data.user_name}, Color: {result.data.color}"
return f"User {result.action}" # pragma: no cover
raise NotImplementedError
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "colors" in params.message and "legacy" not in params.message:
if "colors" in params.message:
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
elif "color" in params.message:
if "legacy" in params.message:
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
else:
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
return ElicitResult(action="decline") # pragma: no cover
if "legacy" in params.message:
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
# Test single-select with titles
await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue")
@ -385,3 +377,28 @@ async def test_elicitation_with_enum_titles():
# Test legacy enumNames format
await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green")
@pytest.mark.anyio
async def test_elicitation_literal_field_renders_as_a_spec_enum_schema():
"""`Literal[...]` and `list[Literal[...]]` render as the spec's enum schemas and pass the gate."""
mcp = MCPServer(name="LiteralServer")
class LiteralSchema(BaseModel):
size: Literal["s", "m", "l"] = Field(description="Size")
extras: list[Literal["a", "b"]] = Field(description="Extras")
@mcp.tool(description="Literal selection")
async def pick(ctx: Context) -> str:
result = await ctx.elicit(message="Pick", schema=LiteralSchema)
if result.action == "accept" and result.data:
return f"{result.data.size}:{','.join(result.data.extras)}"
raise NotImplementedError
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
assert isinstance(params, types.ElicitRequestFormParams)
assert params.requested_schema["properties"]["size"]["enum"] == ["s", "m", "l"]
assert params.requested_schema["properties"]["extras"]["items"]["enum"] == ["a", "b"]
return ElicitResult(action="accept", content={"size": "m", "extras": ["a"]})
await call_tool_and_assert(mcp, callback, "pick", {}, "m:a")

View file

@ -41,7 +41,7 @@ async def test_server_remains_functional_after_cancel():
Tool(
name="test_tool",
description="Tool for testing",
input_schema={},
input_schema={"type": "object"},
)
]
)

View file

@ -8,11 +8,11 @@ shape and inject failures.
import logging
from collections.abc import Mapping
from typing import Any
from typing import Any, Literal
import anyio
import pytest
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from mcp.server.connection import Connection
from mcp.shared.dispatcher import CallOptions
@ -29,6 +29,8 @@ from mcp.types import (
ListRootsRequest,
ListRootsResult,
PingRequest,
Request,
RequestParams,
RootsCapability,
SamplingCapability,
SamplingContextCapability,
@ -144,6 +146,54 @@ async def test_connection_send_request_nonconforming_result_raises_validation_er
await conn.send_request(ListRootsRequest())
@pytest.mark.anyio
async def test_send_request_validates_the_client_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
conn = Connection(StubOutbound(result={"roots": "nope"}), has_standalone_channel=True)
with pytest.raises(ValidationError):
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
"""A spec-valid client result passes the surface gate and parses to the typed model."""
conn = Connection(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}), has_standalone_channel=True)
conn.protocol_version = "2025-11-25"
result = await conn.send_request(ListRootsRequest())
assert isinstance(result, ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
class _CustomRequest(Request[RequestParams | None, Literal["custom/echo"]]):
method: Literal["custom/echo"] = "custom/echo"
params: RequestParams | None = None
class _CustomResult(BaseModel):
value: int
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
the inferred result type validates."""
conn = Connection(StubOutbound(result={}), has_standalone_channel=True)
conn.protocol_version = "2026-07-28"
result = await conn.send_request(PingRequest())
assert isinstance(result, EmptyResult)
@pytest.mark.anyio
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
conn = Connection(StubOutbound(result={"value": 7}), has_standalone_channel=True)
conn.protocol_version = "2025-11-25"
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
assert isinstance(result, _CustomResult)
assert result.value == 7
@pytest.mark.anyio
async def test_connection_ping_sends_ping_on_standalone():
out = StubOutbound()

View file

@ -305,6 +305,48 @@ async def test_runner_on_notify_drops_snake_case_params(server: SrvT, caplog: py
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):
@ -769,6 +811,115 @@ async def test_server_add_request_handler_routes_custom_method_with_validated_pa
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:

View file

@ -9,6 +9,7 @@ from collections.abc import Mapping
from typing import Any, cast
import pytest
from pydantic import ValidationError
from mcp import types
from mcp.server import Server, ServerRequestContext
@ -57,8 +58,10 @@ def _make_session(
*,
capabilities: ClientCapabilities | None = None,
has_standalone_channel: bool = True,
protocol_version: str | None = None,
) -> ServerSession:
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
conn.protocol_version = protocol_version
if capabilities is not None:
conn.client_params = InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
@ -128,6 +131,33 @@ async def test_send_request_without_back_channel_or_related_id_fails_fast():
assert dispatcher.requests[0][3] == 3
@pytest.mark.anyio
async def test_send_request_validates_the_client_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
session = _make_session(StubDispatcher(result={"roots": "nope"}))
with pytest.raises(ValidationError):
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
"""A spec-valid client result passes the surface gate and parses to the typed model."""
session = _make_session(StubDispatcher(result={"roots": [{"uri": "file:///ws"}]}))
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert isinstance(result, types.ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
`result_type` validates."""
session = _make_session(StubDispatcher(result={}), protocol_version="2026-07-28")
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)
@pytest.mark.anyio
async def test_send_request_validates_result_alias_only():
"""Peer results validate alias-only; a snake_case key from the wire is

View file

@ -1,20 +1,34 @@
from typing import Any
import pytest
from inline_snapshot import snapshot
from mcp.types import (
LATEST_PROTOCOL_VERSION,
CallToolResult,
ClientCapabilities,
CompleteResult,
Completion,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
DiscoverResult,
EmptyResult,
GetPromptResult,
Implementation,
InitializeRequest,
InitializeRequestParams,
InputRequiredResult,
JSONRPCRequest,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
ReadResourceResult,
Result,
SamplingCapability,
SamplingMessage,
ServerCapabilities,
TextContent,
Tool,
ToolChoice,
@ -360,3 +374,65 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields():
assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
assert "$defs" in tool.input_schema
assert tool.input_schema["additionalProperties"] is False
def _wire_dump(result: Result) -> dict[str, Any]:
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
def test_concrete_wire_results_always_dump_result_type_complete():
"""Required by 2026-07-28; the runner's per-version sieve drops it for older peers."""
carriers: list[Result] = [
CompleteResult(completion=Completion(values=[])),
GetPromptResult(messages=[]),
CallToolResult(content=[]),
ReadResourceResult(contents=[]),
ListPromptsResult(prompts=[]),
ListResourcesResult(resources=[]),
ListResourceTemplatesResult(resource_templates=[]),
ListToolsResult(tools=[]),
DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
server_info=Implementation(name="server", version="1.0"),
),
]
for result in carriers:
assert _wire_dump(result)["resultType"] == "complete", type(result).__name__
def test_cacheable_results_omit_unset_caching_directives():
"""`ttl_ms`/`cache_scope` default to None: the SDK declares no caching policy
so a 2026-07-28 handler must set them explicitly."""
cacheable: list[Result] = [
ReadResourceResult(contents=[]),
ListPromptsResult(prompts=[]),
ListResourceTemplatesResult(resource_templates=[]),
ListResourcesResult(resources=[]),
ListToolsResult(tools=[]),
DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
server_info=Implementation(name="server", version="1.0"),
),
]
for result in cacheable:
dumped = _wire_dump(result)
assert "ttlMs" not in dumped, type(result).__name__
assert "cacheScope" not in dumped, type(result).__name__
explicit = _wire_dump(ListToolsResult(tools=[], ttl_ms=5, cache_scope="public"))
assert explicit["ttlMs"] == 5
assert explicit["cacheScope"] == "public"
def test_empty_result_dumps_no_fields_by_default():
"""Deployed peers reject extra keys on empty results, so resultType is never volunteered."""
assert _wire_dump(EmptyResult()) == snapshot({})
def test_empty_result_dumps_result_type_only_when_explicitly_tagged():
assert _wire_dump(EmptyResult(result_type="complete")) == snapshot({"resultType": "complete"})
def test_input_required_result_dumps_its_discriminating_tag():
assert _wire_dump(InputRequiredResult()) == snapshot({"resultType": "input_required"})

0
tests/types/__init__.py Normal file
View file

907
tests/types/test_methods.py Normal file
View file

@ -0,0 +1,907 @@
"""Tests for the wire-method maps and two-step parse functions in `mcp.types.methods`."""
import importlib.util
from collections.abc import Mapping
from types import MappingProxyType, UnionType
from typing import Any, get_args
import pydantic
import pytest
from pydantic import BaseModel
import mcp.types as types
import mcp.types.v2025_11_25 as v2025
import mcp.types.v2026_07_28 as v2026
from mcp.shared.version import KNOWN_PROTOCOL_VERSIONS
from mcp.types import methods
# Transcribed from each schema's ClientRequest/ServerRequest/ClientNotification/
# ServerNotification unions, minus the tasks/* family (extensions register those).
EXPECTED_METHODS: dict[str, dict[str, frozenset[str]]] = {
"2024-11-05": {
"CLIENT_REQUESTS": frozenset(
{
"completion/complete",
"initialize",
"logging/setLevel",
"ping",
"prompts/get",
"prompts/list",
"resources/list",
"resources/read",
"resources/subscribe",
"resources/templates/list",
"resources/unsubscribe",
"tools/call",
"tools/list",
}
),
"CLIENT_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/initialized",
"notifications/progress",
"notifications/roots/list_changed",
}
),
"SERVER_REQUESTS": frozenset({"ping", "roots/list", "sampling/createMessage"}),
"SERVER_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/message",
"notifications/progress",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
"notifications/tools/list_changed",
}
),
},
"2025-03-26": {
"CLIENT_REQUESTS": frozenset(
{
"completion/complete",
"initialize",
"logging/setLevel",
"ping",
"prompts/get",
"prompts/list",
"resources/list",
"resources/read",
"resources/subscribe",
"resources/templates/list",
"resources/unsubscribe",
"tools/call",
"tools/list",
}
),
"CLIENT_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/initialized",
"notifications/progress",
"notifications/roots/list_changed",
}
),
"SERVER_REQUESTS": frozenset({"ping", "roots/list", "sampling/createMessage"}),
"SERVER_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/message",
"notifications/progress",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
"notifications/tools/list_changed",
}
),
},
"2025-06-18": {
"CLIENT_REQUESTS": frozenset(
{
"completion/complete",
"initialize",
"logging/setLevel",
"ping",
"prompts/get",
"prompts/list",
"resources/list",
"resources/read",
"resources/subscribe",
"resources/templates/list",
"resources/unsubscribe",
"tools/call",
"tools/list",
}
),
"CLIENT_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/initialized",
"notifications/progress",
"notifications/roots/list_changed",
}
),
"SERVER_REQUESTS": frozenset({"elicitation/create", "ping", "roots/list", "sampling/createMessage"}),
"SERVER_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/message",
"notifications/progress",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
"notifications/tools/list_changed",
}
),
},
"2025-11-25": {
"CLIENT_REQUESTS": frozenset(
{
"completion/complete",
"initialize",
"logging/setLevel",
"ping",
"prompts/get",
"prompts/list",
"resources/list",
"resources/read",
"resources/subscribe",
"resources/templates/list",
"resources/unsubscribe",
"tools/call",
"tools/list",
}
),
"CLIENT_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/initialized",
"notifications/progress",
"notifications/roots/list_changed",
}
),
"SERVER_REQUESTS": frozenset({"elicitation/create", "ping", "roots/list", "sampling/createMessage"}),
"SERVER_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/elicitation/complete",
"notifications/message",
"notifications/progress",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
"notifications/tools/list_changed",
}
),
},
"2026-07-28": {
"CLIENT_REQUESTS": frozenset(
{
"completion/complete",
"prompts/get",
"prompts/list",
"resources/list",
"resources/read",
"resources/templates/list",
"server/discover",
"subscriptions/listen",
"tools/call",
"tools/list",
}
),
"CLIENT_NOTIFICATIONS": frozenset({"notifications/cancelled", "notifications/progress"}),
# No standalone server-to-client request channel at this version.
"SERVER_REQUESTS": frozenset(),
"SERVER_NOTIFICATIONS": frozenset(
{
"notifications/cancelled",
"notifications/elicitation/complete",
"notifications/message",
"notifications/progress",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
"notifications/subscriptions/acknowledged",
"notifications/tools/list_changed",
}
),
},
}
# Pinned per (method, version): class identity, or exact arm tuple for unions.
EXPECTED_SERVER_RESULTS: dict[tuple[str, str], type[BaseModel] | tuple[type[BaseModel], ...]] = {
("completion/complete", "2024-11-05"): v2025.CompleteResult,
("initialize", "2024-11-05"): v2025.InitializeResult,
("logging/setLevel", "2024-11-05"): v2025.EmptyResult,
("ping", "2024-11-05"): v2025.EmptyResult,
("prompts/get", "2024-11-05"): v2025.GetPromptResult,
("prompts/list", "2024-11-05"): v2025.ListPromptsResult,
("resources/list", "2024-11-05"): v2025.ListResourcesResult,
("resources/read", "2024-11-05"): v2025.ReadResourceResult,
("resources/subscribe", "2024-11-05"): v2025.EmptyResult,
("resources/templates/list", "2024-11-05"): v2025.ListResourceTemplatesResult,
("resources/unsubscribe", "2024-11-05"): v2025.EmptyResult,
("tools/call", "2024-11-05"): v2025.CallToolResult,
("tools/list", "2024-11-05"): v2025.ListToolsResult,
("completion/complete", "2025-03-26"): v2025.CompleteResult,
("initialize", "2025-03-26"): v2025.InitializeResult,
("logging/setLevel", "2025-03-26"): v2025.EmptyResult,
("ping", "2025-03-26"): v2025.EmptyResult,
("prompts/get", "2025-03-26"): v2025.GetPromptResult,
("prompts/list", "2025-03-26"): v2025.ListPromptsResult,
("resources/list", "2025-03-26"): v2025.ListResourcesResult,
("resources/read", "2025-03-26"): v2025.ReadResourceResult,
("resources/subscribe", "2025-03-26"): v2025.EmptyResult,
("resources/templates/list", "2025-03-26"): v2025.ListResourceTemplatesResult,
("resources/unsubscribe", "2025-03-26"): v2025.EmptyResult,
("tools/call", "2025-03-26"): v2025.CallToolResult,
("tools/list", "2025-03-26"): v2025.ListToolsResult,
("completion/complete", "2025-06-18"): v2025.CompleteResult,
("initialize", "2025-06-18"): v2025.InitializeResult,
("logging/setLevel", "2025-06-18"): v2025.EmptyResult,
("ping", "2025-06-18"): v2025.EmptyResult,
("prompts/get", "2025-06-18"): v2025.GetPromptResult,
("prompts/list", "2025-06-18"): v2025.ListPromptsResult,
("resources/list", "2025-06-18"): v2025.ListResourcesResult,
("resources/read", "2025-06-18"): v2025.ReadResourceResult,
("resources/subscribe", "2025-06-18"): v2025.EmptyResult,
("resources/templates/list", "2025-06-18"): v2025.ListResourceTemplatesResult,
("resources/unsubscribe", "2025-06-18"): v2025.EmptyResult,
("tools/call", "2025-06-18"): v2025.CallToolResult,
("tools/list", "2025-06-18"): v2025.ListToolsResult,
("completion/complete", "2025-11-25"): v2025.CompleteResult,
("initialize", "2025-11-25"): v2025.InitializeResult,
("logging/setLevel", "2025-11-25"): v2025.EmptyResult,
("ping", "2025-11-25"): v2025.EmptyResult,
("prompts/get", "2025-11-25"): v2025.GetPromptResult,
("prompts/list", "2025-11-25"): v2025.ListPromptsResult,
("resources/list", "2025-11-25"): v2025.ListResourcesResult,
("resources/read", "2025-11-25"): v2025.ReadResourceResult,
("resources/subscribe", "2025-11-25"): v2025.EmptyResult,
("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesResult,
("resources/unsubscribe", "2025-11-25"): v2025.EmptyResult,
("tools/call", "2025-11-25"): v2025.CallToolResult,
("tools/list", "2025-11-25"): v2025.ListToolsResult,
("completion/complete", "2026-07-28"): v2026.CompleteResult,
("prompts/get", "2026-07-28"): (v2026.GetPromptResult, v2026.InputRequiredResult),
("prompts/list", "2026-07-28"): v2026.ListPromptsResult,
("resources/list", "2026-07-28"): v2026.ListResourcesResult,
("resources/read", "2026-07-28"): (v2026.ReadResourceResult, v2026.InputRequiredResult),
("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesResult,
("server/discover", "2026-07-28"): v2026.DiscoverResult,
("subscriptions/listen", "2026-07-28"): v2026.EmptyResult,
("tools/call", "2026-07-28"): (v2026.CallToolResult, v2026.InputRequiredResult),
("tools/list", "2026-07-28"): v2026.ListToolsResult,
}
EXPECTED_CLIENT_RESULTS: dict[tuple[str, str], type[BaseModel] | tuple[type[BaseModel], ...]] = {
("ping", "2024-11-05"): v2025.EmptyResult,
("roots/list", "2024-11-05"): v2025.ListRootsResult,
("sampling/createMessage", "2024-11-05"): v2025.CreateMessageResult,
("ping", "2025-03-26"): v2025.EmptyResult,
("roots/list", "2025-03-26"): v2025.ListRootsResult,
("sampling/createMessage", "2025-03-26"): v2025.CreateMessageResult,
("elicitation/create", "2025-06-18"): v2025.ElicitResult,
("ping", "2025-06-18"): v2025.EmptyResult,
("roots/list", "2025-06-18"): v2025.ListRootsResult,
("sampling/createMessage", "2025-06-18"): v2025.CreateMessageResult,
("elicitation/create", "2025-11-25"): v2025.ElicitResult,
("ping", "2025-11-25"): v2025.EmptyResult,
("roots/list", "2025-11-25"): v2025.ListRootsResult,
("sampling/createMessage", "2025-11-25"): v2025.CreateMessageResult,
}
EMPTY_SERVER_RESPONSE_METHODS = frozenset(
{"logging/setLevel", "ping", "resources/subscribe", "resources/unsubscribe", "subscriptions/listen"}
)
EMPTY_CLIENT_RESPONSE_METHODS = frozenset({"ping"})
# Pre-2026 versions share the 2025-11-25 surface package.
PACKAGE_BY_VERSION = {
"2024-11-05": "mcp.types.v2025_11_25",
"2025-03-26": "mcp.types.v2025_11_25",
"2025-06-18": "mcp.types.v2025_11_25",
"2025-11-25": "mcp.types.v2025_11_25",
"2026-07-28": "mcp.types.v2026_07_28",
}
# The three reserved `params._meta` entries the 2026 surface requires on every request.
META_TRIPLE: dict[str, Any] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "client", "version": "1.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
# One minimal valid params mapping per surface request class.
REQUEST_PARAMS_FIXTURES: dict[type[BaseModel], dict[str, Any] | None] = {
v2025.CallToolRequest: {"name": "echo"},
v2025.CompleteRequest: {"ref": {"type": "ref/prompt", "name": "p"}, "argument": {"name": "a", "value": "v"}},
v2025.CreateMessageRequest: {
"messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}],
"maxTokens": 100,
},
v2025.ElicitRequest: {"message": "m", "requestedSchema": {"type": "object", "properties": {}}},
v2025.GetPromptRequest: {"name": "greeting"},
v2025.InitializeRequest: {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "client", "version": "1.0"},
},
v2025.ListPromptsRequest: None,
v2025.ListResourcesRequest: None,
v2025.ListResourceTemplatesRequest: None,
v2025.ListRootsRequest: None,
v2025.ListToolsRequest: None,
v2025.PingRequest: None,
v2025.ReadResourceRequest: {"uri": "https://example.com/resource"},
v2025.SetLevelRequest: {"level": "info"},
v2025.SubscribeRequest: {"uri": "https://example.com/resource"},
v2025.UnsubscribeRequest: {"uri": "https://example.com/resource"},
v2026.CallToolRequest: {"_meta": META_TRIPLE, "name": "echo"},
v2026.CompleteRequest: {
"_meta": META_TRIPLE,
"ref": {"type": "ref/prompt", "name": "p"},
"argument": {"name": "a", "value": "v"},
},
v2026.DiscoverRequest: {"_meta": META_TRIPLE},
v2026.GetPromptRequest: {"_meta": META_TRIPLE, "name": "greeting"},
v2026.ListPromptsRequest: {"_meta": META_TRIPLE},
v2026.ListResourcesRequest: {"_meta": META_TRIPLE},
v2026.ListResourceTemplatesRequest: {"_meta": META_TRIPLE},
v2026.ListToolsRequest: {"_meta": META_TRIPLE},
v2026.ReadResourceRequest: {"_meta": META_TRIPLE, "uri": "https://example.com/resource"},
v2026.SubscriptionsListenRequest: {"_meta": META_TRIPLE, "notifications": {}},
}
NOTIFICATION_PARAMS_FIXTURES: dict[type[BaseModel], dict[str, Any] | None] = {
v2025.CancelledNotification: {"requestId": 1},
v2025.ElicitationCompleteNotification: {"elicitationId": "e1"},
v2025.InitializedNotification: None,
v2025.LoggingMessageNotification: {"level": "info", "data": "x"},
v2025.ProgressNotification: {"progressToken": 1, "progress": 0.5},
v2025.PromptListChangedNotification: None,
v2025.ResourceListChangedNotification: None,
v2025.ResourceUpdatedNotification: {"uri": "https://example.com/resource"},
v2025.RootsListChangedNotification: None,
v2025.ToolListChangedNotification: None,
v2026.CancelledNotification: {"requestId": 1},
v2026.ElicitationCompleteNotification: {"elicitationId": "e1"},
v2026.LoggingMessageNotification: {"level": "info", "data": "x"},
v2026.ProgressNotification: {"progressToken": 1, "progress": 0.5},
v2026.PromptListChangedNotification: None,
v2026.ResourceListChangedNotification: None,
v2026.ResourceUpdatedNotification: {"uri": "https://example.com/resource"},
v2026.SubscriptionsAcknowledgedNotification: {"notifications": {}},
v2026.ToolListChangedNotification: None,
}
# One minimal valid result body per response row value (class or union alias).
RESULT_BODY_FIXTURES: dict[type[BaseModel] | UnionType, dict[str, Any]] = {
v2025.CallToolResult: {"content": []},
v2025.CompleteResult: {"completion": {"values": []}},
v2025.CreateMessageResult: {"role": "assistant", "content": {"type": "text", "text": "hi"}, "model": "m"},
v2025.ElicitResult: {"action": "accept"},
v2025.EmptyResult: {},
v2025.GetPromptResult: {"messages": []},
v2025.InitializeResult: {
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": {"name": "server", "version": "1.0"},
},
v2025.ListPromptsResult: {"prompts": []},
v2025.ListResourcesResult: {"resources": []},
v2025.ListResourceTemplatesResult: {"resourceTemplates": []},
v2025.ListRootsResult: {"roots": [{"uri": "file:///workspace"}]},
v2025.ListToolsResult: {"tools": []},
v2025.ReadResourceResult: {"contents": []},
v2026.AnyCallToolResult: {"content": [], "resultType": "complete"},
v2026.AnyGetPromptResult: {"messages": [], "resultType": "complete"},
v2026.AnyReadResourceResult: {"contents": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"},
v2026.CompleteResult: {"completion": {"values": []}, "resultType": "complete"},
v2026.DiscoverResult: {
"supportedVersions": ["2026-07-28"],
"capabilities": {},
"serverInfo": {"name": "server", "version": "1.0"},
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
},
v2026.EmptyResult: {"resultType": "complete"},
v2026.ListPromptsResult: {"prompts": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"},
v2026.ListResourcesResult: {"resources": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"},
v2026.ListResourceTemplatesResult: {
"resourceTemplates": [],
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
},
v2026.ListToolsResult: {"tools": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"},
}
def test_maps_define_exactly_the_expected_methods_for_every_known_version():
# Derive the version axis from KNOWN_PROTOCOL_VERSIONS so a new version
# without map rows fails here rather than gating every method at runtime.
assert set(EXPECTED_METHODS) == set(KNOWN_PROTOCOL_VERSIONS)
surface_maps: dict[str, Mapping[tuple[str, str], object]] = {
"CLIENT_REQUESTS": methods.CLIENT_REQUESTS,
"CLIENT_NOTIFICATIONS": methods.CLIENT_NOTIFICATIONS,
"SERVER_REQUESTS": methods.SERVER_REQUESTS,
"SERVER_NOTIFICATIONS": methods.SERVER_NOTIFICATIONS,
}
for version in KNOWN_PROTOCOL_VERSIONS:
for map_name, surface_map in surface_maps.items():
derived = {method for (method, row_version) in surface_map if row_version == version}
assert derived == EXPECTED_METHODS[version][map_name], f"{map_name} at {version}"
def test_spec_client_method_sets_are_the_client_direction_projection_of_the_surface_maps():
assert methods.SPEC_CLIENT_METHODS == {m for m, _ in methods.CLIENT_REQUESTS}
assert methods.SPEC_CLIENT_NOTIFICATION_METHODS == {m for m, _ in methods.CLIENT_NOTIFICATIONS}
# Server-direction methods stay out so a server-side custom registration routes as custom.
assert "roots/list" not in methods.SPEC_CLIENT_METHODS
assert "notifications/message" not in methods.SPEC_CLIENT_NOTIFICATION_METHODS
def test_elicit_result_surface_accepts_null_content_values_at_every_version_that_defines_it():
"""Monolith superset leniency: hosts may answer optional form fields with null."""
for (method, _), surface in methods.CLIENT_RESULTS.items():
if method != "elicitation/create":
continue
assert isinstance(surface, type)
surface.model_validate({"action": "accept", "content": {"name": "x", "age": None}})
for surface in (v2025.ElicitResult, v2026.ElicitResult):
surface.model_validate({"action": "accept", "content": {"name": "x", "age": None}})
def test_server_capabilities_extensions_with_null_json_value_round_trips_at_2026():
"""Spec `JSONValue` includes `null`; the ts->json render dropped it from the vendored schema."""
raw: dict[str, Any] = {"extensions": {"x": {"k": None}}}
parsed = v2026.ServerCapabilities.model_validate(raw)
assert parsed.model_dump(mode="json")["extensions"] == {"x": {"k": None}}
def test_elicit_request_surface_accepts_loose_property_schemas():
"""Older python-sdk emits `anyOf` for `Optional` form fields; the surface gate must let it through."""
params = {
"message": "m",
"requestedSchema": {
"type": "object",
"properties": {"x": {"anyOf": [{"type": "integer"}, {"type": "null"}]}},
},
}
parsed = methods.parse_server_request("elicitation/create", "2025-11-25", params)
assert isinstance(parsed, types.ElicitRequest)
def test_response_map_keys_mirror_the_request_map_keys():
assert set(methods.SERVER_RESULTS) == set(methods.CLIENT_REQUESTS)
assert set(methods.CLIENT_RESULTS) == set(methods.SERVER_REQUESTS)
def test_response_row_values_match_the_pinned_classes_and_unions():
"""Only the known empty-response methods may be valued by the bare `EmptyResult`."""
assert set(EXPECTED_SERVER_RESULTS) == set(methods.SERVER_RESULTS)
assert set(EXPECTED_CLIENT_RESULTS) == set(methods.CLIENT_RESULTS)
pinned = [
(methods.SERVER_RESULTS, EXPECTED_SERVER_RESULTS, EMPTY_SERVER_RESPONSE_METHODS),
(methods.CLIENT_RESULTS, EXPECTED_CLIENT_RESULTS, EMPTY_CLIENT_RESPONSE_METHODS),
]
for response_map, expected_rows, empty_methods in pinned:
for (method, version), expected in expected_rows.items():
actual = response_map[(method, version)]
if isinstance(expected, tuple):
assert get_args(actual) == expected, f"{method} at {version}"
else:
assert actual is expected, f"{method} at {version}"
if method not in empty_methods:
assert actual is not v2025.EmptyResult, f"{method} at {version}"
assert actual is not v2026.EmptyResult, f"{method} at {version}"
def test_surface_keys_agree_with_their_classes_and_the_monolith_maps():
"""Each surface key's method matches its class's method literal, its monolith row, and its version's package."""
request_maps: list[Mapping[tuple[str, str], type[BaseModel]]] = [
methods.CLIENT_REQUESTS,
methods.SERVER_REQUESTS,
]
notification_maps: list[Mapping[tuple[str, str], type[BaseModel]]] = [
methods.CLIENT_NOTIFICATIONS,
methods.SERVER_NOTIFICATIONS,
]
for surface_maps, monolith_map in (
(request_maps, methods.MONOLITH_REQUESTS),
(notification_maps, methods.MONOLITH_NOTIFICATIONS),
):
for surface_map in surface_maps:
for (method, version), surface_type in surface_map.items():
assert method in monolith_map, f"{method} has no monolith row"
assert get_args(surface_type.model_fields["method"].annotation) == (method,)
assert get_args(monolith_map[method].model_fields["method"].annotation) == (method,)
assert surface_type.__module__ == PACKAGE_BY_VERSION[version], f"{method} at {version}"
for response_map in (methods.SERVER_RESULTS, methods.CLIENT_RESULTS):
for (method, version), row in response_map.items():
assert method in methods.MONOLITH_RESULTS, f"{method} has no monolith row"
for arm in get_args(row) or (row,):
assert arm.__module__ == PACKAGE_BY_VERSION[version], f"{method} at {version}"
def _assign_item(mapping: Any) -> None:
mapping["new-key"] = None
def test_built_in_maps_are_immutable():
map_names = [
"CLIENT_NOTIFICATIONS",
"CLIENT_REQUESTS",
"CLIENT_RESULTS",
"MONOLITH_NOTIFICATIONS",
"MONOLITH_REQUESTS",
"MONOLITH_RESULTS",
"SERVER_NOTIFICATIONS",
"SERVER_REQUESTS",
"SERVER_RESULTS",
]
for map_name in map_names:
built_in = getattr(methods, map_name)
assert isinstance(built_in, MappingProxyType), map_name
with pytest.raises(TypeError):
_assign_item(built_in)
def test_minimal_request_bodies_parse_through_every_request_row():
for (method, version), surface_type in methods.CLIENT_REQUESTS.items():
parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type])
assert isinstance(parsed, types.Request), f"{method} at {version}"
for (method, version), surface_type in methods.SERVER_REQUESTS.items():
parsed = methods.parse_server_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type])
assert isinstance(parsed, types.Request), f"{method} at {version}"
def test_minimal_notification_bodies_parse_through_every_notification_row():
for (method, version), surface_type in methods.CLIENT_NOTIFICATIONS.items():
parsed = methods.parse_client_notification(method, version, NOTIFICATION_PARAMS_FIXTURES[surface_type])
assert isinstance(parsed, types.Notification), f"{method} at {version}"
for (method, version), surface_type in methods.SERVER_NOTIFICATIONS.items():
parsed = methods.parse_server_notification(method, version, NOTIFICATION_PARAMS_FIXTURES[surface_type])
assert isinstance(parsed, types.Notification), f"{method} at {version}"
def test_minimal_result_bodies_parse_through_every_result_row():
for (method, version), row in methods.SERVER_RESULTS.items():
parsed = methods.parse_server_result(method, version, RESULT_BODY_FIXTURES[row])
assert isinstance(parsed, types.Result), f"{method} at {version}"
for (method, version), row in methods.CLIENT_RESULTS.items():
parsed = methods.parse_client_result(method, version, RESULT_BODY_FIXTURES[row])
assert isinstance(parsed, types.Result), f"{method} at {version}"
def test_non_file_root_uri_passes_the_surface_step_and_rejects_at_the_monolith_step():
"""The monolith's `Root.uri` is file-scheme only; the surfaces declare a plain string."""
non_file_roots = {"roots": [{"uri": "https://example.com/x"}]}
# Surface step admits the body, so the two-step parse fails at the monolith step.
pydantic.TypeAdapter(v2025.ListRootsResult).validate_python(non_file_roots)
with pytest.raises(pydantic.ValidationError):
methods.parse_client_result("roots/list", "2025-11-25", non_file_roots)
# Same divergence on the 2026 path that embeds a roots response.
retry_params = {"_meta": META_TRIPLE, "name": "echo", "inputResponses": {"r1": non_file_roots}}
frame = {"jsonrpc": "2.0", "id": 0, "method": "tools/call", "params": retry_params}
v2026.CallToolRequest.model_validate(frame, by_name=False)
with pytest.raises(pydantic.ValidationError):
methods.parse_client_request("tools/call", "2026-07-28", retry_params)
file_roots = {"roots": [{"uri": "file:///workspace"}]}
assert isinstance(methods.parse_client_result("roots/list", "2025-11-25", file_roots), types.ListRootsResult)
retried = methods.parse_client_request(
"tools/call", "2026-07-28", {"_meta": META_TRIPLE, "name": "echo", "inputResponses": {"r1": file_roots}}
)
assert isinstance(retried, types.CallToolRequest)
def test_absent_map_keys_raise_key_error_for_every_gate_shape():
"""Key absence is the version gate; the session layer maps it to `METHOD_NOT_FOUND`."""
gated = [
("resources/subscribe", "2026-07-28"), # removed at this version
("server/discover", "2025-11-25"), # not yet at this version
("tasks/get", "2025-11-25"), # never built-in
("sampling/createMessage", "2025-11-25"), # wrong direction
]
for method, version in gated:
with pytest.raises(KeyError):
methods.parse_client_request(method, version, None)
with pytest.raises(KeyError):
methods.parse_server_request("ping", "2026-07-28", None)
def test_unknown_version_strings_raise_value_error_on_every_parse_function():
body_parsers = [
methods.parse_client_request,
methods.parse_server_request,
methods.parse_client_notification,
methods.parse_server_notification,
]
for body_parser in body_parsers:
with pytest.raises(ValueError) as excinfo:
body_parser("ping", "2099-01-01", None)
assert "2099-01-01" in str(excinfo.value)
result_parsers = [methods.parse_server_result, methods.parse_client_result]
for result_parser in result_parsers:
with pytest.raises(ValueError) as excinfo:
result_parser("ping", "2099-01-01", {})
assert "2099-01-01" in str(excinfo.value)
def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing():
for absent_key in META_TRIPLE:
partial_meta = {key: value for key, value in META_TRIPLE.items() if key != absent_key}
with pytest.raises(pydantic.ValidationError) as excinfo:
methods.parse_client_request("tools/list", "2026-07-28", {"_meta": partial_meta})
assert [error["loc"] for error in excinfo.value.errors() if error["type"] == "missing"] == [
("params", "_meta", absent_key)
]
def test_2026_07_28_results_require_result_type():
with pytest.raises(pydantic.ValidationError):
methods.parse_server_result("tools/call", "2026-07-28", {"content": []})
with pytest.raises(pydantic.ValidationError):
methods.parse_server_result("subscriptions/listen", "2026-07-28", {})
def test_empty_result_body_parses_at_versions_that_define_it():
parsed = methods.parse_server_result("ping", "2025-11-25", {})
assert isinstance(parsed, types.EmptyResult)
def test_2026_07_28_shaped_result_extras_pass_at_earlier_versions():
"""The earlier surface ignores unknown keys; the monolith preserves them on fields it declares."""
parsed = methods.parse_server_result(
"tools/list", "2025-11-25", {"tools": [], "resultType": "complete", "ttlMs": 5, "cacheScope": "public"}
)
assert isinstance(parsed, types.ListToolsResult)
assert parsed.result_type == "complete"
assert parsed.ttl_ms == 5
assert parsed.cache_scope == "public"
def test_embedded_input_request_entries_without_method_reject_at_the_surface_step():
"""The monolith's embedded request classes default `method`, so only the surface step rejects this."""
body = {"resultType": "input_required", "inputRequests": {"r1": {"params": None}}}
monolith_row = methods.MONOLITH_RESULTS["tools/call"]
monolith_only: types.Result = pydantic.TypeAdapter[Any](monolith_row).validate_python(body)
assert isinstance(monolith_only, types.InputRequiredResult)
with pytest.raises(pydantic.ValidationError):
methods.parse_server_result("tools/call", "2026-07-28", body)
def test_none_params_omit_the_key_so_required_params_reject():
with pytest.raises(pydantic.ValidationError) as excinfo:
methods.parse_client_request("tools/call", "2025-11-25", None)
assert [error["loc"] for error in excinfo.value.errors() if error["type"] == "missing"] == [("params",)]
assert isinstance(methods.parse_client_request("ping", "2025-11-25", None), types.PingRequest)
def test_snake_case_spellings_of_required_aliased_fields_reject_as_missing():
"""Wire parsing is alias-only (`by_name=False`), at both the surface and monolith steps."""
snake_params = {"messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}], "max_tokens": 100}
with pytest.raises(pydantic.ValidationError) as excinfo:
methods.parse_server_request("sampling/createMessage", "2025-11-25", snake_params)
assert [error["loc"] for error in excinfo.value.errors() if error["type"] == "missing"] == [("params", "maxTokens")]
with pytest.raises(pydantic.ValidationError):
types.CreateMessageRequest.model_validate(
{"method": "sampling/createMessage", "params": snake_params}, by_name=False
)
def test_extension_map_rows_parse_through_the_same_functions():
extended_surface = {**methods.CLIENT_REQUESTS, ("tasks/get", "2025-11-25"): v2025.GetTaskRequest}
extended_monolith = {**methods.MONOLITH_REQUESTS, "tasks/get": types.GetTaskRequest}
parsed = methods.parse_client_request(
"tasks/get", "2025-11-25", {"taskId": "t1"}, surface=extended_surface, monolith=extended_monolith
)
assert isinstance(parsed, types.GetTaskRequest)
assert parsed.params.task_id == "t1"
def test_inconsistent_extension_maps_raise_runtime_error_after_the_surface_hit():
"""Must not raise `KeyError`: the session layer treats that as the version gate."""
extended_surface = {**methods.CLIENT_REQUESTS, ("tasks/get", "2025-11-25"): v2025.GetTaskRequest}
with pytest.raises(RuntimeError, match="inconsistent extension maps"):
methods.parse_client_request("tasks/get", "2025-11-25", {"taskId": "t1"}, surface=extended_surface)
def test_input_required_unions_discriminate_identically_in_both_arm_orders():
complete_bodies: dict[str, dict[str, Any]] = {
"tools/call": {"content": []},
"prompts/get": {"messages": []},
"resources/read": {"contents": []},
}
shared_bodies: list[dict[str, Any]] = [
{"resultType": "input_required", "inputRequests": {"r1": {"method": "roots/list"}}},
{"resultType": "input_required", "requestState": "blob"},
]
for method, complete_body in complete_bodies.items():
row = methods.MONOLITH_RESULTS[method]
complete_arm, input_required_arm = get_args(row)
assert input_required_arm is types.InputRequiredResult
bodies: list[dict[str, Any]] = [
complete_body,
{**complete_body, "resultType": "complete"},
*shared_bodies,
{**complete_body, "resultType": "task"}, # open tag is preserved
{**complete_body, "resultType": "input_required"}, # complete shape plus the tag
]
for body in bodies:
forward = pydantic.TypeAdapter[Any](complete_arm | input_required_arm).validate_python(body)
reversed_order = pydantic.TypeAdapter[Any](input_required_arm | complete_arm).validate_python(body)
assert type(forward) is type(reversed_order), f"{method}: {body}"
assert forward.result_type == reversed_order.result_type
through_row = pydantic.TypeAdapter[Any](row).validate_python(complete_body)
assert isinstance(through_row, complete_arm)
open_tagged = pydantic.TypeAdapter[Any](row).validate_python({**complete_body, "resultType": "task"})
assert open_tagged.result_type == "task"
def test_sampling_union_keeps_the_complete_arm_first_because_order_is_load_bearing():
"""A single-block body satisfies both arms; smart-union ties resolve leftmost."""
assert get_args(methods.MONOLITH_RESULTS["sampling/createMessage"]) == (
types.CreateMessageResult,
types.CreateMessageResultWithTools,
)
single_block: dict[str, Any] = {"role": "assistant", "content": {"type": "text", "text": "hi"}, "model": "m"}
through_row = methods.parse_client_result("sampling/createMessage", "2025-11-25", single_block)
assert type(through_row) is types.CreateMessageResult
reversed_union = pydantic.TypeAdapter[Any](types.CreateMessageResultWithTools | types.CreateMessageResult)
assert type(reversed_union.validate_python(single_block)) is types.CreateMessageResultWithTools
array_body: dict[str, Any] = {"role": "assistant", "content": [{"type": "text", "text": "hi"}], "model": "m"}
tool_use_body: dict[str, Any] = {
"role": "assistant",
"content": {"type": "tool_use", "name": "t", "id": "c1", "input": {}},
"model": "m",
}
for body in (array_body, tool_use_body):
parsed = methods.parse_client_result("sampling/createMessage", "2025-11-25", body)
assert type(parsed) is types.CreateMessageResultWithTools
def test_validate_functions_accept_reject_and_gate_like_their_parse_siblings():
methods.validate_client_request("tools/call", "2025-11-25", {"name": "echo"})
methods.validate_client_notification("notifications/cancelled", "2025-11-25", {"requestId": 1})
methods.validate_server_result("tools/list", "2025-11-25", {"tools": []})
methods.validate_client_result("roots/list", "2025-11-25", {"roots": []})
with pytest.raises(KeyError):
methods.validate_client_request("custom/greet", "2025-11-25", None)
with pytest.raises(KeyError):
methods.validate_client_notification("custom/ping", "2025-11-25", None)
with pytest.raises(KeyError):
methods.validate_server_result("custom/greet", "2025-11-25", {})
with pytest.raises(KeyError):
methods.validate_client_result("roots/list", "2026-07-28", {})
with pytest.raises(pydantic.ValidationError):
methods.validate_client_request("tools/call", "2025-11-25", None)
with pytest.raises(pydantic.ValidationError):
methods.validate_client_notification("notifications/progress", "2025-11-25", {"progressToken": []})
with pytest.raises(pydantic.ValidationError):
methods.validate_server_result("tools/list", "2025-11-25", {"tools": 42})
with pytest.raises(pydantic.ValidationError):
methods.validate_client_result("roots/list", "2025-11-25", {"roots": 42})
with pytest.raises(ValueError):
methods.validate_client_request("ping", "2099-01-01", None)
# One minimal monolith result instance per request method, dumped via the same
# `_dump_result` path the runner uses. Cacheable results set `ttl_ms`/`cache_scope`
# explicitly because the monolith no longer defaults them and 2026 requires them.
MONOLITH_RESULT_FIXTURES: dict[str, types.Result] = {
"completion/complete": types.CompleteResult(completion=types.Completion(values=[])),
"initialize": types.InitializeResult(
protocol_version="2025-11-25",
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="server", version="1.0"),
),
"logging/setLevel": types.EmptyResult(),
"ping": types.EmptyResult(),
"prompts/get": types.GetPromptResult(messages=[]),
"prompts/list": types.ListPromptsResult(prompts=[], ttl_ms=0, cache_scope="private"),
"resources/list": types.ListResourcesResult(resources=[], ttl_ms=0, cache_scope="private"),
"resources/read": types.ReadResourceResult(contents=[], ttl_ms=0, cache_scope="private"),
"resources/subscribe": types.EmptyResult(),
"resources/templates/list": types.ListResourceTemplatesResult(
resource_templates=[], ttl_ms=0, cache_scope="private"
),
"resources/unsubscribe": types.EmptyResult(),
"server/discover": types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="server", version="1.0"),
ttl_ms=0,
cache_scope="private",
),
"subscriptions/listen": types.EmptyResult(result_type="complete"),
"tools/call": types.CallToolResult(content=[]),
"tools/list": types.ListToolsResult(tools=[], ttl_ms=0, cache_scope="private"),
}
CACHEABLE_METHODS = frozenset(m for m, r in MONOLITH_RESULT_FIXTURES.items() if isinstance(r, types.CacheableResult))
@pytest.mark.parametrize(("method", "version"), sorted(methods.SERVER_RESULTS))
def test_dumped_monolith_results_round_trip_through_serialize_server_result(method: str, version: str):
"""The outbound sieve must accept every correctly-typed handler return and re-validate."""
instance = MONOLITH_RESULT_FIXTURES[method]
dumped = instance.model_dump(by_alias=True, mode="json", exclude_none=True)
sieved = methods.serialize_server_result(method, version, dumped)
methods.validate_server_result(method, version, sieved)
PRE_2026 = [v for v in KNOWN_PROTOCOL_VERSIONS if v < "2026-07-28"]
@pytest.mark.parametrize(("method", "version"), [k for k in sorted(methods.SERVER_RESULTS) if k[1] in PRE_2026])
def test_serialize_server_result_drops_2026_only_keys_at_pre_2026_versions(method: str, version: str):
instance = MONOLITH_RESULT_FIXTURES[method]
dumped = instance.model_dump(by_alias=True, mode="json", exclude_none=True)
sieved = methods.serialize_server_result(method, version, dumped)
if getattr(instance, "result_type", None) is not None:
assert "resultType" in dumped
assert "resultType" not in sieved
if method in CACHEABLE_METHODS:
assert "ttlMs" in dumped and "cacheScope" in dumped
assert "ttlMs" not in sieved
assert "cacheScope" not in sieved
def test_serialize_server_result_keeps_2026_only_keys_at_2026_07_28():
dumped = MONOLITH_RESULT_FIXTURES["tools/list"].model_dump(by_alias=True, mode="json", exclude_none=True)
sieved = methods.serialize_server_result("tools/list", "2026-07-28", dumped)
assert sieved["resultType"] == "complete"
assert sieved["ttlMs"] == 0
assert sieved["cacheScope"] == "private"
@pytest.mark.parametrize("version", KNOWN_PROTOCOL_VERSIONS)
def test_serialize_server_result_preserves_arbitrary_meta_value_identically(version: str):
meta = {"custom-key": 1, "modelcontextprotocol.io/foo": "bar"}
dumped: dict[str, Any] = {"tools": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "_meta": meta}
sieved = methods.serialize_server_result("tools/list", version, dumped)
assert sieved["_meta"] == meta
def test_serialize_server_result_preserves_open_type_extras():
"""`inputSchema` and nested `_meta` are open key-value bags; the sieve must not strip them."""
input_schema = {"type": "object", "title": "X", "additionalProperties": False, "$defs": {"Y": {"type": "string"}}}
nested_meta = {"com.example/tag": "v"}
tool = {"name": "echo", "inputSchema": input_schema, "_meta": nested_meta}
sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool]})
assert sieved["tools"][0]["inputSchema"] == input_schema
assert sieved["tools"][0]["_meta"] == nested_meta
def test_serialize_server_result_drops_an_unknown_nested_tool_field():
tool = {"name": "echo", "inputSchema": {"type": "object"}, "unknownField": 1}
sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool], "resultType": "complete"})
assert sieved == {"tools": [{"name": "echo", "inputSchema": {"type": "object"}}]}
def test_serialize_server_result_raises_key_error_for_an_absent_row_and_value_error_for_an_unknown_version():
with pytest.raises(KeyError):
methods.serialize_server_result("server/discover", "2025-11-25", {})
with pytest.raises(ValueError):
methods.serialize_server_result("ping", "2099-01-01", {})
def test_importing_the_module_builds_no_adapters_and_identical_rows_share_one():
# Execute a fresh copy so the cache assertion is order-independent.
spec = importlib.util.find_spec("mcp.types.methods")
assert spec is not None and spec.loader is not None
fresh = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fresh)
assert fresh._adapter.cache_info().currsize == 0
fresh.parse_server_result("ping", "2025-11-25", {})
assert fresh._adapter.cache_info().currsize == 2
# Identical row values at another version: no new adapters.
fresh.parse_server_result("ping", "2024-11-05", {})
assert fresh._adapter.cache_info().currsize == 2

219
tests/types/test_parity.py Normal file
View file

@ -0,0 +1,219 @@
"""Assert every per-version surface model's wire fields are a subset of its `mcp.types` superset counterpart."""
from __future__ import annotations
import inspect
from types import ModuleType
import pytest
from pydantic import BaseModel
import mcp.types as monolith
import mcp.types._types as _types
import mcp.types.v2025_11_25 as v2025_11_25
import mcp.types.v2026_07_28 as v2026_07_28
SURFACES: tuple[ModuleType, ...] = (v2025_11_25, v2026_07_28)
# Envelope fields the monolith models on `mcp.types.jsonrpc` instead of on each request/notification.
ENVELOPE_FIELDS: frozenset[str] = frozenset({"jsonrpc", "id"})
# Surface classes whose monolith counterpart has a different name (key: "<surface_tail>.<ClassName>").
NAME_MAP: dict[str, type[BaseModel]] = {
# v2025_11_25
"v2025_11_25.Argument": monolith.CompletionArgument,
"v2025_11_25.Context": monolith.CompletionContext,
"v2025_11_25.Data": monolith.ElicitationRequiredErrorData,
"v2025_11_25.Elicitation": monolith.ElicitationCapability,
"v2025_11_25.Elicitation1": monolith.TasksElicitationCapability,
"v2025_11_25.ElicitationCompleteNotification": monolith.ElicitCompleteNotification,
"v2025_11_25.Params": monolith.CancelTaskRequestParams,
"v2025_11_25.Params1": monolith.ElicitCompleteNotificationParams,
"v2025_11_25.Params2": monolith.GetTaskPayloadRequestParams,
"v2025_11_25.Params3": monolith.GetTaskRequestParams,
"v2025_11_25.Error": monolith.ErrorData,
"v2025_11_25.JSONRPCErrorResponse": monolith.JSONRPCError,
"v2025_11_25.JSONRPCResultResponse": monolith.JSONRPCResponse,
"v2025_11_25.Prompts": monolith.PromptsCapability,
"v2025_11_25.Requests": monolith.ClientTasksRequestsCapability,
"v2025_11_25.Requests1": monolith.ServerTasksRequestsCapability,
"v2025_11_25.Resources": monolith.ResourcesCapability,
"v2025_11_25.Roots": monolith.RootsCapability,
"v2025_11_25.Sampling": monolith.SamplingCapability,
"v2025_11_25.Sampling1": monolith.TasksSamplingCapability,
"v2025_11_25.Tasks": monolith.ClientTasksCapability,
"v2025_11_25.Tasks1": monolith.ServerTasksCapability,
"v2025_11_25.Tools": monolith.TasksToolsCapability,
"v2025_11_25.Tools1": monolith.ToolsCapability,
# v2026_07_28
"v2026_07_28.Argument": monolith.CompletionArgument,
"v2026_07_28.Context": monolith.CompletionContext,
"v2026_07_28.Data": monolith.MissingRequiredClientCapabilityErrorData,
"v2026_07_28.Data1": monolith.UnsupportedProtocolVersionErrorData,
"v2026_07_28.Elicitation": monolith.ElicitationCapability,
"v2026_07_28.ElicitationCompleteNotification": monolith.ElicitCompleteNotification,
"v2026_07_28.Params": monolith.ElicitCompleteNotificationParams,
"v2026_07_28.Error": monolith.ErrorData,
"v2026_07_28.JSONRPCErrorResponse": monolith.JSONRPCError,
"v2026_07_28.JSONRPCResultResponse": monolith.JSONRPCResponse,
"v2026_07_28.Prompts": monolith.PromptsCapability,
"v2026_07_28.Resources": monolith.ResourcesCapability,
"v2026_07_28.Sampling": monolith.SamplingCapability,
"v2026_07_28.Tools": monolith.ToolsCapability,
}
# Surface classes with no monolith equivalent (envelope wrappers, JSON-Schema fragments modelled as `dict`).
SKIP: frozenset[str] = frozenset(
{
# v2025_11_25
"v2025_11_25.AnyOfItem",
"v2025_11_25.BooleanSchema",
"v2025_11_25.Error1",
"v2025_11_25.Icons",
"v2025_11_25.InputSchema",
"v2025_11_25.Items",
"v2025_11_25.Items1",
"v2025_11_25.LegacyTitledEnumSchema",
"v2025_11_25.Meta",
"v2025_11_25.NumberSchema",
"v2025_11_25.OneOfItem",
"v2025_11_25.OutputSchema",
"v2025_11_25.RequestedSchema",
"v2025_11_25.ResourceRequestParams",
"v2025_11_25.StringSchema",
"v2025_11_25.TaskAugmentedRequestParams",
"v2025_11_25.TitledMultiSelectEnumSchema",
"v2025_11_25.TitledSingleSelectEnumSchema",
"v2025_11_25.URLElicitationRequiredError",
"v2025_11_25.UntitledMultiSelectEnumSchema",
"v2025_11_25.UntitledSingleSelectEnumSchema",
# v2026_07_28
"v2026_07_28.AnyOfItem",
"v2026_07_28.BooleanSchema",
"v2026_07_28.CallToolResultResponse",
"v2026_07_28.CompleteResultResponse",
"v2026_07_28.DiscoverResultResponse",
"v2026_07_28.Error1",
"v2026_07_28.Error2",
"v2026_07_28.GetPromptResultResponse",
"v2026_07_28.Icons",
"v2026_07_28.InputSchema",
"v2026_07_28.InternalError",
"v2026_07_28.InvalidParamsError",
"v2026_07_28.InvalidRequestError",
"v2026_07_28.Items",
"v2026_07_28.Items1",
"v2026_07_28.LegacyTitledEnumSchema",
"v2026_07_28.ListPromptsResultResponse",
"v2026_07_28.ListResourceTemplatesResultResponse",
"v2026_07_28.ListResourcesResultResponse",
"v2026_07_28.ListToolsResultResponse",
"v2026_07_28.MetaObject",
"v2026_07_28.MethodNotFoundError",
"v2026_07_28.MissingRequiredClientCapabilityError",
"v2026_07_28.NumberSchema",
"v2026_07_28.OneOfItem",
"v2026_07_28.OutputSchema",
"v2026_07_28.ParseError",
"v2026_07_28.ReadResourceResultResponse",
"v2026_07_28.RequestMetaObject",
"v2026_07_28.RequestedSchema",
"v2026_07_28.ResourceRequestParams",
"v2026_07_28.StringSchema",
"v2026_07_28.TitledMultiSelectEnumSchema",
"v2026_07_28.TitledSingleSelectEnumSchema",
"v2026_07_28.UnsupportedProtocolVersionError",
"v2026_07_28.UntitledMultiSelectEnumSchema",
"v2026_07_28.UntitledSingleSelectEnumSchema",
}
)
# Intentional gaps: (surface class, wire alias) -> reason the monolith omits the field.
_RESULT_TYPE_REASON = "resultType is declared on each concrete Result subclass, not the base"
FIELD_EXCEPTIONS: dict[tuple[type[BaseModel], str], str] = {
(v2026_07_28.Result, "resultType"): _RESULT_TYPE_REASON,
(v2026_07_28.PaginatedResult, "resultType"): _RESULT_TYPE_REASON,
(v2026_07_28.CacheableResult, "resultType"): _RESULT_TYPE_REASON,
}
def _wire_aliases(model: type[BaseModel]) -> set[str]:
return {field.alias or name for name, field in model.model_fields.items()}
def _surface_classes(module: ModuleType) -> list[tuple[str, type[BaseModel]]]:
tail = module.__name__.rsplit(".", 1)[-1]
out: list[tuple[str, type[BaseModel]]] = []
for name, obj in vars(module).items():
if not (inspect.isclass(obj) and issubclass(obj, BaseModel)):
continue
if obj.__module__ != module.__name__ or obj.__name__ != name:
continue # re-export or alias to another model
if getattr(obj, "__pydantic_root_model__", False):
continue # RootModel alias wrapper; the field-subset property does not apply
out.append((f"{tail}.{name}", obj))
return out
def _matched_pairs() -> list[tuple[str, type[BaseModel], type[BaseModel]]]:
pairs: list[tuple[str, type[BaseModel], type[BaseModel]]] = []
for module in SURFACES:
for qualname, surface_cls in _surface_classes(module):
if qualname in SKIP:
continue
mono_cls = (
NAME_MAP.get(qualname)
or getattr(monolith, surface_cls.__name__, None)
or getattr(_types, surface_cls.__name__, None)
)
assert isinstance(mono_cls, type) and issubclass(mono_cls, BaseModel), qualname
pairs.append((qualname, surface_cls, mono_cls))
return pairs
@pytest.mark.parametrize(
"qualname,surface_cls,mono_cls", _matched_pairs(), ids=lambda v: v if isinstance(v, str) else ""
)
def test_monolith_is_superset_of_surface_fields(
qualname: str, surface_cls: type[BaseModel], mono_cls: type[BaseModel]
) -> None:
surface_fields = _wire_aliases(surface_cls) - ENVELOPE_FIELDS
excused = {alias for (cls, alias) in FIELD_EXCEPTIONS if cls is surface_cls}
missing = surface_fields - _wire_aliases(mono_cls) - excused
assert not missing, f"{qualname}: monolith {mono_cls.__name__} missing wire fields {sorted(missing)}"
# Monolith model classes intentionally kept out of `mcp.types.__all__`.
PRIVATE_MONOLITH_MODELS: frozenset[str] = frozenset(
{
"MCPModel", # internal base; users subclass the concrete spec types instead
}
)
def test_every_public_monolith_model_is_exported_from_mcp_types() -> None:
defined = {
name
for name, obj in vars(_types).items()
if name.isidentifier() # skip pydantic's `Request[...]` generic-alias entries
and not name.startswith("_")
and inspect.isclass(obj)
and issubclass(obj, BaseModel)
and obj.__module__ == _types.__name__
}
missing = defined - set(monolith.__all__) - PRIVATE_MONOLITH_MODELS
assert not missing, f"_types models not in mcp.types.__all__: {sorted(missing)}"
def test_every_surface_class_is_accounted_for() -> None:
monolith_models = {
name
for name, obj in (vars(monolith) | vars(_types)).items()
if inspect.isclass(obj) and issubclass(obj, BaseModel)
}
surface = {q: cls.__name__ for module in SURFACES for q, cls in _surface_classes(module)}
auto_matched = {q for q, name in surface.items() if name in monolith_models}
unmapped = surface.keys() - auto_matched - NAME_MAP.keys() - SKIP
assert not unmapped, f"surface classes with no mapping: {sorted(unmapped)}"
stale = (NAME_MAP.keys() | SKIP) - surface.keys()
assert not stale, f"stale NAME_MAP/SKIP entries: {sorted(stale)}"

View file

@ -0,0 +1,89 @@
"""Snapshot pins for outbound JSON-RPC frames; a diff is a wire-visible change needing a deliberate decision."""
from typing import Any
from inline_snapshot import snapshot
from pydantic import BaseModel
from mcp.types import (
METHOD_NOT_FOUND,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
EmptyResult,
ErrorData,
InputRequiredResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListRootsRequest,
ListToolsResult,
ProgressNotification,
ProgressNotificationParams,
TextContent,
Tool,
)
def _body(model: BaseModel) -> dict[str, Any]:
"""Mirror the session layer's outbound payload dump."""
return model.model_dump(by_alias=True, mode="json", exclude_none=True)
def _frame(envelope: BaseModel) -> str:
"""Mirror the transports' frame serialization."""
return envelope.model_dump_json(by_alias=True, exclude_unset=True)
def test_request_frame_carries_the_envelope_and_the_dumped_request_body():
request = CallToolRequest(params=CallToolRequestParams(name="echo", arguments={"text": "hi"}))
frame = JSONRPCRequest(jsonrpc="2.0", id=1, **_body(request))
assert _frame(frame) == snapshot(
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}'
)
def test_notification_frame_has_no_id_and_carries_the_dumped_params():
notification = ProgressNotification(params=ProgressNotificationParams(progress_token="t1", progress=0.5))
frame = JSONRPCNotification(jsonrpc="2.0", **_body(notification))
assert _frame(frame) == snapshot(
'{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t1","progress":0.5}}'
)
def test_non_empty_result_dump_carries_result_type_complete_before_the_sieve():
"""The runner's per-version sieve drops `resultType` for pre-2026 peers; the raw dump carries it."""
result = CallToolResult(content=[TextContent(text="ok")])
frame = JSONRPCResponse(jsonrpc="2.0", id=1, result=_body(result))
assert _frame(frame) == snapshot(
'{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}],"isError":false,"resultType":"complete"}}'
)
def test_cacheable_list_result_dump_omits_unset_caching_directives():
"""`ttl_ms`/`cache_scope` default to None so the raw dump omits them; 2026 handlers set them explicitly."""
result = ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
frame = JSONRPCResponse(jsonrpc="2.0", id=2, result=_body(result))
assert _frame(frame) == snapshot(
'{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}],"resultType":"complete"}}'
)
def test_empty_result_frame_dumps_an_empty_result_object():
"""Deployed peers reject extra keys on empty results, so the SDK omits resultType here."""
frame = JSONRPCResponse(jsonrpc="2.0", id=3, result=_body(EmptyResult()))
assert _frame(frame) == snapshot('{"jsonrpc":"2.0","id":3,"result":{}}')
def test_input_required_result_frame_carries_the_tag_and_the_embedded_requests():
result = InputRequiredResult(input_requests={"r1": ListRootsRequest()}, request_state="s1")
frame = JSONRPCResponse(jsonrpc="2.0", id=4, result=_body(result))
assert _frame(frame) == snapshot(
'{"jsonrpc":"2.0","id":4,"result":{"resultType":"input_required","inputRequests":{"r1":{"method":"roots/list"}},"requestState":"s1"}}'
)
def test_error_frame_wraps_error_data_in_the_jsonrpc_envelope():
frame = JSONRPCError(jsonrpc="2.0", id=5, error=ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
assert _frame(frame) == snapshot('{"jsonrpc":"2.0","id":5,"error":{"code":-32601,"message":"Method not found"}}')