Protocol types for 2026-07-28: superset monolith, committed per-version packages, and wire-method maps (#2849)
This commit is contained in:
parent
2693fb95ae
commit
65be5a7147
15 changed files with 19076 additions and 371 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])],
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
0
tests/types/__init__.py
Normal file
907
tests/types/test_methods.py
Normal file
907
tests/types/test_methods.py
Normal 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
219
tests/types/test_parity.py
Normal 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)}"
|
||||
89
tests/types/test_wire_frames.py
Normal file
89
tests/types/test_wire_frames.py
Normal 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"}}')
|
||||
Loading…
Reference in a new issue