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

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

View file

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

View file

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

View file

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

View file

@ -305,6 +305,48 @@ async def test_runner_on_notify_drops_snake_case_params(server: SrvT, caplog: py
assert "dropped 'notifications/roots/list_changed': malformed params" in caplog.text
@pytest.mark.anyio
async def test_runner_on_notify_drops_a_spec_notification_absent_at_the_negotiated_version(
server: SrvT, caplog: pytest.LogCaptureFixture
):
"""`notifications/roots/list_changed` is a client notification but not at
2026-07-28; the version gate drops it before handler lookup."""
barrier = anyio.Event()
async def dropped(ctx: Ctx, params: NotificationParams) -> None:
raise NotImplementedError # the version gate drops the notification first
async def on_progress(ctx: Ctx, params: ProgressNotificationParams) -> None:
barrier.set()
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, dropped)
server.add_notification_handler("notifications/progress", ProgressNotificationParams, on_progress)
with caplog.at_level("DEBUG", logger="mcp.server.runner"):
async with connected_runner(server) as (client, runner):
runner.connection.protocol_version = "2026-07-28"
await client.notify("notifications/roots/list_changed", None)
await client.notify("notifications/progress", {"progressToken": 1, "progress": 0.5})
await barrier.wait()
assert "dropped 'notifications/roots/list_changed': not defined at 2026-07-28" in caplog.text
@pytest.mark.anyio
async def test_runner_on_notify_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT):
"""`notifications/message` is a spec method but server-to-client only; on
a server it is a custom registration (proxy use) and must reach the
handler, not the client-direction version gate."""
seen: list[NotificationParams] = []
async def handler(ctx: Ctx, params: NotificationParams) -> None:
seen.append(params)
server.add_notification_handler("notifications/message", NotificationParams, handler)
async with connected_runner(server) as (client, _):
await client.notify("notifications/message", {"level": "info", "data": "x"})
await client.send_raw_request("tools/list", None)
assert len(seen) == 1
@pytest.mark.anyio
async def test_runner_on_notify_initialized_sets_flag_and_connection_event(server: SrvT):
async with connected_runner(server, initialized=False) as (client, runner):
@ -769,6 +811,115 @@ async def test_server_add_request_handler_routes_custom_method_with_validated_pa
assert received[0].name == "world"
@pytest.mark.anyio
async def test_runner_spec_method_with_invalid_params_is_invalid_params_at_the_negotiated_version(server: SrvT):
async with connected_runner(server) as (client, runner):
assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/call", {"name": 42})
assert exc.value.error.code == INVALID_PARAMS
@pytest.mark.anyio
async def test_runner_handler_returning_malformed_dict_for_spec_method_is_internal_error(server: SrvT):
async def bad_result(ctx: Ctx, params: PaginatedRequestParams | None) -> dict[str, Any]:
return {"tools": 42}
server.add_request_handler("tools/list", PaginatedRequestParams, bad_result)
async with connected_runner(server) as (client, _):
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/list", None)
assert exc.value.error.code == INTERNAL_ERROR
assert exc.value.error.message == "Handler returned an invalid result"
# Result body must not reach the client; detail belongs in the server log.
assert exc.value.error.data is None
@pytest.mark.anyio
async def test_runner_handler_returning_typed_monolith_result_passes_outbound_validation(server: SrvT):
async with connected_runner(server) as (client, _):
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
@pytest.mark.anyio
async def test_runner_outbound_sieve_drops_2026_only_result_keys_at_a_pre_2026_version(server: SrvT):
"""The handler's `resultType`/`ttlMs`/`cacheScope` are sieved out so a 2025
client sees only schema fields."""
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})], ttl_ms=5, cache_scope="public")
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with connected_runner(server) as (client, runner):
assert runner.connection.protocol_version == "2025-11-25"
result = await client.send_raw_request("tools/list", None)
assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]}
@pytest.mark.anyio
async def test_runner_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT):
"""`roots/list` is a spec method but server-to-client only; on a server it
is a custom registration (proxy use) and must reach the handler, not the
client-direction version gate."""
async def list_roots(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"roots": [{"uri": "file:///workspace"}]}
server.add_request_handler("roots/list", RequestParams, list_roots)
async with connected_runner(server) as (client, _):
result = await client.send_raw_request("roots/list", None)
assert result == {"roots": [{"uri": "file:///workspace"}]}
@pytest.mark.anyio
async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not_found(server: SrvT):
"""`server/discover` is a spec method (in `MONOLITH_REQUESTS`) but only at
2026-07-28; on a 2025 session it must be METHOD_NOT_FOUND even with a
registered handler."""
async def discover(ctx: Ctx, params: RequestParams) -> Any:
raise NotImplementedError # the version gate rejects the request first
server.add_request_handler("server/discover", RequestParams, discover)
async with connected_runner(server) as (client, runner):
assert runner.connection.protocol_version == "2025-11-25"
with pytest.raises(MCPError) as exc:
await client.send_raw_request("server/discover", None)
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="server/discover")
@pytest.mark.anyio
async def test_runner_middleware_short_circuit_on_a_wrong_version_spec_method_skips_the_sieve(server: SrvT):
"""A server-tier middleware that returns without calling `call_next` for a
spec method absent at the negotiated version owns the result shape; the
outbound sieve has no `(method, version)` row and must not raise."""
async def short_circuit(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
if method == "server/discover":
return {"ok": True}
return await call_next()
server.middleware.append(short_circuit)
async with connected_runner(server) as (client, runner):
assert runner.connection.protocol_version == "2025-11-25"
result = await client.send_raw_request("server/discover", None)
assert result == {"ok": True}
@pytest.mark.anyio
async def test_runner_custom_method_result_is_not_surface_validated(server: SrvT):
"""No `SERVER_RESULTS` row for a custom method, so its result reaches the client as-is."""
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"anything": "goes"}
server.add_request_handler("custom/greet", RequestParams, custom)
async with connected_runner(server) as (client, _):
result = await client.send_raw_request("custom/greet", None)
assert result == {"anything": "goes"}
@pytest.mark.anyio
async def test_runner_initialize_result_reflects_init_options():
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:

View file

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