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
4 changed files with 19076 additions and 371 deletions

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

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

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

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

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

View file

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