chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,954 @@
|
||||
"""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 mcp_types as types
|
||||
import mcp_types.v2025_11_25 as v2025
|
||||
import mcp_types.v2026_07_28 as v2026
|
||||
import pydantic
|
||||
import pytest
|
||||
from mcp_types import methods
|
||||
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
|
||||
from pydantic import BaseModel
|
||||
|
||||
# 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"}),
|
||||
# No standalone server-to-client request channel at this version.
|
||||
"SERVER_REQUESTS": frozenset(),
|
||||
"SERVER_NOTIFICATIONS": frozenset(
|
||||
{
|
||||
"notifications/cancelled",
|
||||
"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.SubscriptionsListenResult,
|
||||
("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"})
|
||||
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.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.SubscriptionsListenResult: {
|
||||
"resultType": "complete",
|
||||
"_meta": {"io.modelcontextprotocol/subscriptionId": 1},
|
||||
},
|
||||
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_cacheable_methods_mirror_the_cacheable_method_literal():
|
||||
"""SEP-2549 weld: the hand-written Literal and the set derived from `MONOLITH_RESULTS` must agree."""
|
||||
assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod))
|
||||
|
||||
|
||||
def test_input_required_methods_mirror_the_monolith_input_required_arms():
|
||||
"""MRTR weld: the spec's three multi-round-trip carriers are the only input_required methods."""
|
||||
assert methods.INPUT_REQUIRED_METHODS == frozenset({"prompts/get", "resources/read", "tools/call"})
|
||||
|
||||
|
||||
def test_is_input_required_matches_typed_and_wire_shapes():
|
||||
"""SDK-defined predicate: True only for the typed model and the tagged wire mapping."""
|
||||
assert methods.is_input_required(types.InputRequiredResult(request_state="s"))
|
||||
assert methods.is_input_required({"resultType": "input_required", "inputRequests": {}})
|
||||
assert not methods.is_input_required({"resultType": "complete", "content": []})
|
||||
assert not methods.is_input_required({})
|
||||
assert not methods.is_input_required(types.CallToolResult(content=[]))
|
||||
assert not methods.is_input_required(None)
|
||||
|
||||
|
||||
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_input_required_url_elicit_without_elicitation_id_parses_at_2026():
|
||||
"""A 2026-07-28 `InputRequiredResult` embedding a URL-mode elicitation parses
|
||||
through both the surface and monolith steps without `elicitationId`.
|
||||
|
||||
Spec-mandated: the field is required at 2025-11-25 only and removed at
|
||||
2026-07-28; the monolith model carries it as optional so the superset can
|
||||
accept both versions.
|
||||
"""
|
||||
body = {
|
||||
"resultType": "input_required",
|
||||
"inputRequests": {
|
||||
"r1": {
|
||||
"method": "elicitation/create",
|
||||
"params": {"mode": "url", "message": "Please sign in", "url": "https://example.com/auth"},
|
||||
}
|
||||
},
|
||||
}
|
||||
parsed = methods.parse_server_result("tools/call", "2026-07-28", body)
|
||||
assert isinstance(parsed, types.InputRequiredResult)
|
||||
assert parsed.input_requests is not None
|
||||
request = parsed.input_requests["r1"]
|
||||
assert isinstance(request, types.ElicitRequest)
|
||||
assert isinstance(request.params, types.ElicitRequestURLParams)
|
||||
assert request.params.url == "https://example.com/auth"
|
||||
assert request.params.elicitation_id is None
|
||||
|
||||
|
||||
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.SubscriptionsListenResult.model_validate(
|
||||
{"_meta": {"io.modelcontextprotocol/subscriptionId": 1}}
|
||||
),
|
||||
"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
|
||||
@@ -0,0 +1,222 @@
|
||||
"""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 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
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
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.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.ClientNotification",
|
||||
"v2026_07_28.CompleteResultResponse",
|
||||
"v2026_07_28.DiscoverResultResponse",
|
||||
"v2026_07_28.Error1",
|
||||
"v2026_07_28.Error2",
|
||||
"v2026_07_28.Error3",
|
||||
"v2026_07_28.GetPromptResultResponse",
|
||||
"v2026_07_28.HeaderMismatchError",
|
||||
"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.NotificationMetaObject",
|
||||
"v2026_07_28.NumberSchema",
|
||||
"v2026_07_28.OneOfItem",
|
||||
"v2026_07_28.OutputSchema",
|
||||
"v2026_07_28.Params",
|
||||
"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.SubscriptionsListenResultMeta",
|
||||
"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)}"
|
||||
@@ -0,0 +1,37 @@
|
||||
"""`Request.name_param`: the wire-params key a request type declares for `Mcp-Name` emission."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import mcp_types as types
|
||||
from mcp_types import CallToolRequest, PingRequest, Request
|
||||
|
||||
|
||||
class _VendorParams(types.RequestParams):
|
||||
task_id: str
|
||||
|
||||
|
||||
class _VendorRequest(Request[_VendorParams, Literal["vendor/tasks/get"]]):
|
||||
method: Literal["vendor/tasks/get"] = "vendor/tasks/get"
|
||||
name_param = "taskId"
|
||||
|
||||
|
||||
def test_request_base_declares_no_name_param() -> None:
|
||||
assert Request.name_param is None
|
||||
|
||||
|
||||
def test_core_request_types_inherit_none() -> None:
|
||||
assert CallToolRequest.name_param is None
|
||||
assert PingRequest.name_param is None
|
||||
|
||||
|
||||
def test_subclass_overrides_by_bare_assignment() -> None:
|
||||
"""Subclasses set `name_param` by bare assignment; the override is class-local."""
|
||||
assert _VendorRequest.name_param == "taskId"
|
||||
assert Request.name_param is None
|
||||
|
||||
|
||||
def test_name_param_is_not_a_pydantic_field() -> None:
|
||||
request = _VendorRequest(params=_VendorParams(task_id="t-1"))
|
||||
assert "name_param" not in _VendorRequest.model_fields
|
||||
dumped = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
assert dumped == {"method": "vendor/tasks/get", "params": {"taskId": "t-1"}}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the protocol-version registry and comparison helpers."""
|
||||
|
||||
import pytest
|
||||
from mcp_types.version import (
|
||||
HANDSHAKE_PROTOCOL_VERSIONS,
|
||||
KNOWN_PROTOCOL_VERSIONS,
|
||||
MODERN_PROTOCOL_VERSIONS,
|
||||
is_version_at_least,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "minimum", "expected"),
|
||||
[
|
||||
# equal
|
||||
("2025-11-25", "2025-11-25", True),
|
||||
("2024-11-05", "2024-11-05", True),
|
||||
# above
|
||||
("2025-11-25", "2025-06-18", True),
|
||||
("2025-06-18", "2024-11-05", True),
|
||||
# below
|
||||
("2025-06-18", "2025-11-25", False),
|
||||
("2024-11-05", "2025-03-26", False),
|
||||
],
|
||||
)
|
||||
def test_is_version_at_least_ordering(version: str, minimum: str, expected: bool) -> None:
|
||||
"""Known revisions order by registry position: equal, newer, and older pairs."""
|
||||
assert is_version_at_least(version, minimum) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["zzz", "", "2025-11-26", "draft", "9999-99-99"])
|
||||
def test_is_version_at_least_unknown_version_is_false(version: str) -> None:
|
||||
"""Unrecognized peer strings compare conservatively, never accidentally."""
|
||||
assert is_version_at_least(version, "2024-11-05") is False
|
||||
|
||||
|
||||
def test_is_version_at_least_unknown_minimum_raises() -> None:
|
||||
"""An unknown minimum is programmer error, not peer input."""
|
||||
with pytest.raises(ValueError, match="zzz"):
|
||||
is_version_at_least("2025-11-25", "zzz")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "minimum"), [(v, m) for v in KNOWN_PROTOCOL_VERSIONS for m in KNOWN_PROTOCOL_VERSIONS]
|
||||
)
|
||||
def test_is_version_at_least_matches_lexicographic_for_known_versions(version: str, minimum: str) -> None:
|
||||
"""Drop-in equivalence: for every known (date-shaped) revision pair the helper
|
||||
agrees with the string comparison it replaced."""
|
||||
assert is_version_at_least(version, minimum) is (version >= minimum)
|
||||
|
||||
|
||||
def test_supported_versions_are_known() -> None:
|
||||
"""Every negotiable revision must be in the ordering registry."""
|
||||
assert set(HANDSHAKE_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
|
||||
assert set(MODERN_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
|
||||
|
||||
|
||||
def test_known_versions_are_strictly_ordered() -> None:
|
||||
"""The registry tuple is the ordering source of truth: ascending, no duplicates."""
|
||||
assert list(KNOWN_PROTOCOL_VERSIONS) == sorted(set(KNOWN_PROTOCOL_VERSIONS))
|
||||
@@ -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 mcp_types import (
|
||||
METHOD_NOT_FOUND,
|
||||
CallToolRequest,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
InputRequiredResult,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ListRootsRequest,
|
||||
ListToolsResult,
|
||||
ProgressNotification,
|
||||
ProgressNotificationParams,
|
||||
TextContent,
|
||||
Tool,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
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_carries_default_caching_directives():
|
||||
"""`ttl_ms`/`cache_scope` default to 0/"private" so the raw dump carries them; the
|
||||
runner's per-version sieve drops them for pre-2026 peers."""
|
||||
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":{"ttlMs":0,"cacheScope":"private","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"}}')
|
||||
Reference in New Issue
Block a user