Protocol types for 2026-07-28: superset monolith, committed per-version packages, and wire-method maps (#2849)
This commit is contained in:
parent
2693fb95ae
commit
65be5a7147
5 changed files with 19076 additions and 371 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"""Test the elicitation feature over the in-memory client transport."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -175,6 +175,14 @@ async def test_elicitation_with_optional_fields():
|
|||
for content, expected in test_cases:
|
||||
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
# Optional fields render as the bare primitive (no anyOf), absent from `required`.
|
||||
assert params.requested_schema["properties"]["optional_age"] == {
|
||||
"type": "integer",
|
||||
"title": "Optional Age",
|
||||
"description": "Your age (optional)",
|
||||
}
|
||||
assert params.requested_schema["required"] == ["required_name"]
|
||||
return ElicitResult(action="accept", content=content)
|
||||
|
||||
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
|
||||
|
|
@ -203,45 +211,32 @@ async def test_elicitation_with_optional_fields():
|
|||
text_contains=["Validation failed:", "optional_list"],
|
||||
)
|
||||
|
||||
# Test valid list[str] for multi-select enum
|
||||
class ValidMultiSelectSchema(BaseModel):
|
||||
# Bare `list[str]` renders without enum items and so is not a spec MultiSelectEnumSchema.
|
||||
class BareListSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] = Field(description="Tags")
|
||||
|
||||
@mcp.tool(description="Tool with valid list[str] field")
|
||||
async def valid_multiselect_tool(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="Please provide tags", schema=ValidMultiSelectSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "Please provide tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
await call_tool_and_assert(mcp, multiselect_callback, "valid_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2")
|
||||
|
||||
# Test Optional[list[str]] for optional multi-select enum
|
||||
class OptionalMultiSelectSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] | None = Field(default=None, description="Optional tags")
|
||||
|
||||
@mcp.tool(description="Tool with optional list[str] field")
|
||||
async def optional_multiselect_tool(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="Please provide optional tags", schema=OptionalMultiSelectSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
tags_str = ", ".join(result.data.tags) if result.data.tags else "none"
|
||||
return f"Name: {result.data.name}, Tags: {tags_str}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def optional_multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "Please provide optional tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
def make_reject_tool(tool_name: str, schema_cls: type[BaseModel]) -> None:
|
||||
@mcp.tool(name=tool_name, description="Tool with a rejected field")
|
||||
async def _tool(ctx: Context) -> str:
|
||||
try:
|
||||
await ctx.elicit(message="Provide value", schema=schema_cls)
|
||||
except TypeError as e:
|
||||
return f"Validation failed: {str(e)}"
|
||||
raise NotImplementedError
|
||||
|
||||
make_reject_tool("bare_list_tool", BareListSchema)
|
||||
await call_tool_and_assert(
|
||||
mcp, optional_multiselect_callback, "optional_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2"
|
||||
mcp, elicitation_callback, "bare_list_tool", {}, text_contains=["Validation failed:", "tags"]
|
||||
)
|
||||
|
||||
# A union of two primitives renders as `anyOf`, outside `PrimitiveSchemaDefinition`.
|
||||
class MultiPrimitiveSchema(BaseModel):
|
||||
value: int | str = Field(description="Value")
|
||||
|
||||
make_reject_tool("multi_primitive_tool", MultiPrimitiveSchema)
|
||||
await call_tool_and_assert(
|
||||
mcp, elicitation_callback, "multi_primitive_tool", {}, text_contains=["Validation failed:", "value"]
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -328,7 +323,22 @@ async def test_elicitation_with_enum_titles():
|
|||
return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test multi-select with titles using anyOf
|
||||
# Test legacy enumNames format
|
||||
class LegacyColorSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
color: str = Field(
|
||||
description="Select a color",
|
||||
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Legacy enum format")
|
||||
async def select_color_legacy(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Color: {result.data.color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test multi-select with titles using items.anyOf
|
||||
class FavoriteColorsSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
favorite_colors: list[str] = Field(
|
||||
|
|
@ -350,32 +360,14 @@ async def test_elicitation_with_enum_titles():
|
|||
result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test legacy enumNames format
|
||||
class LegacyColorSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
color: str = Field(
|
||||
description="Select a color",
|
||||
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Legacy enum format")
|
||||
async def select_color_legacy(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Color: {result.data.color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "colors" in params.message and "legacy" not in params.message:
|
||||
if "colors" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
|
||||
elif "color" in params.message:
|
||||
if "legacy" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
|
||||
else:
|
||||
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
if "legacy" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
|
||||
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
|
||||
|
||||
# Test single-select with titles
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue")
|
||||
|
|
@ -385,3 +377,28 @@ async def test_elicitation_with_enum_titles():
|
|||
|
||||
# Test legacy enumNames format
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_literal_field_renders_as_a_spec_enum_schema():
|
||||
"""`Literal[...]` and `list[Literal[...]]` render as the spec's enum schemas and pass the gate."""
|
||||
mcp = MCPServer(name="LiteralServer")
|
||||
|
||||
class LiteralSchema(BaseModel):
|
||||
size: Literal["s", "m", "l"] = Field(description="Size")
|
||||
extras: list[Literal["a", "b"]] = Field(description="Extras")
|
||||
|
||||
@mcp.tool(description="Literal selection")
|
||||
async def pick(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="Pick", schema=LiteralSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"{result.data.size}:{','.join(result.data.extras)}"
|
||||
raise NotImplementedError
|
||||
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
assert params.requested_schema["properties"]["size"]["enum"] == ["s", "m", "l"]
|
||||
assert params.requested_schema["properties"]["extras"]["items"]["enum"] == ["a", "b"]
|
||||
return ElicitResult(action="accept", content={"size": "m", "extras": ["a"]})
|
||||
|
||||
await call_tool_and_assert(mcp, callback, "pick", {}, "m:a")
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ async def test_server_remains_functional_after_cancel():
|
|||
Tool(
|
||||
name="test_tool",
|
||||
description="Tool for testing",
|
||||
input_schema={},
|
||||
input_schema={"type": "object"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ shape and inject failures.
|
|||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
|
|
@ -29,6 +29,8 @@ from mcp.types import (
|
|||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
PingRequest,
|
||||
Request,
|
||||
RequestParams,
|
||||
RootsCapability,
|
||||
SamplingCapability,
|
||||
SamplingContextCapability,
|
||||
|
|
@ -144,6 +146,54 @@ async def test_connection_send_request_nonconforming_result_raises_validation_er
|
|||
await conn.send_request(ListRootsRequest())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_validates_the_client_result_against_the_surface_schema():
|
||||
"""A spec-method result that fails the per-version surface schema raises
|
||||
`ValidationError` even when the caller's `result_type` would accept it."""
|
||||
conn = Connection(StubOutbound(result={"roots": "nope"}), has_standalone_channel=True)
|
||||
with pytest.raises(ValidationError):
|
||||
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_passes_a_spec_valid_client_result():
|
||||
"""A spec-valid client result passes the surface gate and parses to the typed model."""
|
||||
conn = Connection(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2025-11-25"
|
||||
result = await conn.send_request(ListRootsRequest())
|
||||
assert isinstance(result, ListRootsResult)
|
||||
assert str(result.roots[0].uri) == "file:///ws"
|
||||
|
||||
|
||||
class _CustomRequest(Request[RequestParams | None, Literal["custom/echo"]]):
|
||||
method: Literal["custom/echo"] = "custom/echo"
|
||||
params: RequestParams | None = None
|
||||
|
||||
|
||||
class _CustomResult(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
|
||||
"""Surface row absent for the negotiated version: gate is bypassed and only
|
||||
the inferred result type validates."""
|
||||
conn = Connection(StubOutbound(result={}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2026-07-28"
|
||||
result = await conn.send_request(PingRequest())
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
|
||||
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
|
||||
conn = Connection(StubOutbound(result={"value": 7}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2025-11-25"
|
||||
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
|
||||
assert isinstance(result, _CustomResult)
|
||||
assert result.value == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_ping_sends_ping_on_standalone():
|
||||
out = StubOutbound()
|
||||
|
|
|
|||
|
|
@ -305,6 +305,48 @@ async def test_runner_on_notify_drops_snake_case_params(server: SrvT, caplog: py
|
|||
assert "dropped 'notifications/roots/list_changed': malformed params" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_on_notify_drops_a_spec_notification_absent_at_the_negotiated_version(
|
||||
server: SrvT, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""`notifications/roots/list_changed` is a client notification but not at
|
||||
2026-07-28; the version gate drops it before handler lookup."""
|
||||
barrier = anyio.Event()
|
||||
|
||||
async def dropped(ctx: Ctx, params: NotificationParams) -> None:
|
||||
raise NotImplementedError # the version gate drops the notification first
|
||||
|
||||
async def on_progress(ctx: Ctx, params: ProgressNotificationParams) -> None:
|
||||
barrier.set()
|
||||
|
||||
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, dropped)
|
||||
server.add_notification_handler("notifications/progress", ProgressNotificationParams, on_progress)
|
||||
with caplog.at_level("DEBUG", logger="mcp.server.runner"):
|
||||
async with connected_runner(server) as (client, runner):
|
||||
runner.connection.protocol_version = "2026-07-28"
|
||||
await client.notify("notifications/roots/list_changed", None)
|
||||
await client.notify("notifications/progress", {"progressToken": 1, "progress": 0.5})
|
||||
await barrier.wait()
|
||||
assert "dropped 'notifications/roots/list_changed': not defined at 2026-07-28" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_on_notify_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT):
|
||||
"""`notifications/message` is a spec method but server-to-client only; on
|
||||
a server it is a custom registration (proxy use) and must reach the
|
||||
handler, not the client-direction version gate."""
|
||||
seen: list[NotificationParams] = []
|
||||
|
||||
async def handler(ctx: Ctx, params: NotificationParams) -> None:
|
||||
seen.append(params)
|
||||
|
||||
server.add_notification_handler("notifications/message", NotificationParams, handler)
|
||||
async with connected_runner(server) as (client, _):
|
||||
await client.notify("notifications/message", {"level": "info", "data": "x"})
|
||||
await client.send_raw_request("tools/list", None)
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_on_notify_initialized_sets_flag_and_connection_event(server: SrvT):
|
||||
async with connected_runner(server, initialized=False) as (client, runner):
|
||||
|
|
@ -769,6 +811,115 @@ async def test_server_add_request_handler_routes_custom_method_with_validated_pa
|
|||
assert received[0].name == "world"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_spec_method_with_invalid_params_is_invalid_params_at_the_negotiated_version(server: SrvT):
|
||||
async with connected_runner(server) as (client, runner):
|
||||
assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/call", {"name": 42})
|
||||
assert exc.value.error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_handler_returning_malformed_dict_for_spec_method_is_internal_error(server: SrvT):
|
||||
async def bad_result(ctx: Ctx, params: PaginatedRequestParams | None) -> dict[str, Any]:
|
||||
return {"tools": 42}
|
||||
|
||||
server.add_request_handler("tools/list", PaginatedRequestParams, bad_result)
|
||||
async with connected_runner(server) as (client, _):
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("tools/list", None)
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert exc.value.error.message == "Handler returned an invalid result"
|
||||
# Result body must not reach the client; detail belongs in the server log.
|
||||
assert exc.value.error.data is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_handler_returning_typed_monolith_result_passes_outbound_validation(server: SrvT):
|
||||
async with connected_runner(server) as (client, _):
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result["tools"][0]["name"] == "t"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_outbound_sieve_drops_2026_only_result_keys_at_a_pre_2026_version(server: SrvT):
|
||||
"""The handler's `resultType`/`ttlMs`/`cacheScope` are sieved out so a 2025
|
||||
client sees only schema fields."""
|
||||
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})], ttl_ms=5, cache_scope="public")
|
||||
|
||||
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
|
||||
async with connected_runner(server) as (client, runner):
|
||||
assert runner.connection.protocol_version == "2025-11-25"
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT):
|
||||
"""`roots/list` is a spec method but server-to-client only; on a server it
|
||||
is a custom registration (proxy use) and must reach the handler, not the
|
||||
client-direction version gate."""
|
||||
|
||||
async def list_roots(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"roots": [{"uri": "file:///workspace"}]}
|
||||
|
||||
server.add_request_handler("roots/list", RequestParams, list_roots)
|
||||
async with connected_runner(server) as (client, _):
|
||||
result = await client.send_raw_request("roots/list", None)
|
||||
assert result == {"roots": [{"uri": "file:///workspace"}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not_found(server: SrvT):
|
||||
"""`server/discover` is a spec method (in `MONOLITH_REQUESTS`) but only at
|
||||
2026-07-28; on a 2025 session it must be METHOD_NOT_FOUND even with a
|
||||
registered handler."""
|
||||
|
||||
async def discover(ctx: Ctx, params: RequestParams) -> Any:
|
||||
raise NotImplementedError # the version gate rejects the request first
|
||||
|
||||
server.add_request_handler("server/discover", RequestParams, discover)
|
||||
async with connected_runner(server) as (client, runner):
|
||||
assert runner.connection.protocol_version == "2025-11-25"
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("server/discover", None)
|
||||
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="server/discover")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_middleware_short_circuit_on_a_wrong_version_spec_method_skips_the_sieve(server: SrvT):
|
||||
"""A server-tier middleware that returns without calling `call_next` for a
|
||||
spec method absent at the negotiated version owns the result shape; the
|
||||
outbound sieve has no `(method, version)` row and must not raise."""
|
||||
|
||||
async def short_circuit(ctx: Ctx, method: str, params: Any, call_next: Any) -> Any:
|
||||
if method == "server/discover":
|
||||
return {"ok": True}
|
||||
return await call_next()
|
||||
|
||||
server.middleware.append(short_circuit)
|
||||
async with connected_runner(server) as (client, runner):
|
||||
assert runner.connection.protocol_version == "2025-11-25"
|
||||
result = await client.send_raw_request("server/discover", None)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_custom_method_result_is_not_surface_validated(server: SrvT):
|
||||
"""No `SERVER_RESULTS` row for a custom method, so its result reaches the client as-is."""
|
||||
|
||||
async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"anything": "goes"}
|
||||
|
||||
server.add_request_handler("custom/greet", RequestParams, custom)
|
||||
async with connected_runner(server) as (client, _):
|
||||
result = await client.send_raw_request("custom/greet", None)
|
||||
assert result == {"anything": "goes"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_initialize_result_reflects_init_options():
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from collections.abc import Mapping
|
|||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
|
@ -57,8 +58,10 @@ def _make_session(
|
|||
*,
|
||||
capabilities: ClientCapabilities | None = None,
|
||||
has_standalone_channel: bool = True,
|
||||
protocol_version: str | None = None,
|
||||
) -> ServerSession:
|
||||
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
|
||||
conn.protocol_version = protocol_version
|
||||
if capabilities is not None:
|
||||
conn.client_params = InitializeRequestParams(
|
||||
protocol_version=LATEST_PROTOCOL_VERSION,
|
||||
|
|
@ -128,6 +131,33 @@ async def test_send_request_without_back_channel_or_related_id_fails_fast():
|
|||
assert dispatcher.requests[0][3] == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_validates_the_client_result_against_the_surface_schema():
|
||||
"""A spec-method result that fails the per-version surface schema raises
|
||||
`ValidationError` even when the caller's `result_type` would accept it."""
|
||||
session = _make_session(StubDispatcher(result={"roots": "nope"}))
|
||||
with pytest.raises(ValidationError):
|
||||
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_passes_a_spec_valid_client_result():
|
||||
"""A spec-valid client result passes the surface gate and parses to the typed model."""
|
||||
session = _make_session(StubDispatcher(result={"roots": [{"uri": "file:///ws"}]}))
|
||||
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
|
||||
assert isinstance(result, types.ListRootsResult)
|
||||
assert str(result.roots[0].uri) == "file:///ws"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
|
||||
"""Surface row absent for the negotiated version: gate is bypassed and only
|
||||
`result_type` validates."""
|
||||
session = _make_session(StubDispatcher(result={}), protocol_version="2026-07-28")
|
||||
result = await session.send_request(types.PingRequest(), types.EmptyResult)
|
||||
assert isinstance(result, types.EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_validates_result_alias_only():
|
||||
"""Peer results validate alias-only; a snake_case key from the wire is
|
||||
|
|
|
|||
Loading…
Reference in a new issue