Align with spec #3002: optional clientInfo, serverInfo in result _meta (#3143)

This commit is contained in:
Max 2026-07-23 12:00:36 +01:00 • committed by GitHub
commit 837ef904f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1757 additions and 567 deletions

View file

@ -33,6 +33,7 @@ from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
@ -137,7 +138,7 @@ async def test_session_serves_requests_after_cancellation(connect: Connect) -> N
@requirement("protocol:cancel:unknown-id-ignored")
async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None:
async def test_cancellation_for_unknown_request_is_ignored(connect: Connect, unstamped: Unstamp) -> None:
"""A cancellation referencing a request id that is not in flight is ignored without error."""
async def list_tools(
@ -157,7 +158,7 @@ async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) ->
)
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
@requirement("protocol:cancel:server-to-client")
@ -350,7 +351,7 @@ async def test_timed_out_initialize_sends_no_cancellation() -> None:
@requirement("protocol:cancel:abort-signal")
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect, unstamped: Unstamp) -> None:
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
the server-side handler is interrupted, and the session serves later requests normally.
@ -397,11 +398,11 @@ async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> N
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
result = await client.call_tool("echo", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")]))
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("protocol:cancel:abort-scoped")
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect, unstamped: Unstamp) -> None:
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
abandoning the first interrupts only its handler and the second returns its result.
@ -464,4 +465,6 @@ async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Con
# dropped while the client is still open, so teardown never races its delivery.
await anyio.wait_all_tasks_blocked()
assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])])
assert [unstamped(result) for result in results] == snapshot(
[CallToolResult(content=[TextContent(text="survived")])]
)

View file

@ -25,6 +25,7 @@ from mcp_types import (
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
DiscoverResult,
Implementation,
@ -123,13 +124,13 @@ async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -
"""`Client(..., mode=<pin>, prior_discover=...)` sends nothing on entry and exposes the prior server_info.
Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained
DiscoverResult is installed via `adopt()` so server_info and capabilities are available
immediately with zero round trips.
DiscoverResult is installed via `adopt()` so server_info (read from the result's `_meta`
serverInfo stamp) and capabilities are available immediately with zero round trips.
"""
prior = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)),
server_info=Implementation(name="cached-server", version="9.9.9"),
_meta={SERVER_INFO_META_KEY: {"name": "cached-server", "version": "9.9.9"}},
)
requests, on_request = _request_recorder()
@ -156,7 +157,8 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a
single `server/discover` request whose result carries supported versions, capabilities,
server_info and the cache-hint fields, after which the session is modern-negotiated.
the cache-hint fields, and the `_meta` serverInfo stamp, after which the session is
modern-negotiated.
"""
requests, on_request = _request_recorder()
server = _tools_server("discoverable")
@ -167,6 +169,7 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.server_info is not None
assert client.server_info.name == "discoverable"
await client.list_tools()
@ -198,7 +201,6 @@ async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version()
return DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="picky", version="1.0.0"),
)
server = _tools_server("picky")
@ -309,6 +311,7 @@ async def test_auto_mode_falls_back_to_initialize_on_a_legacy_probe_rejection(
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.server_info is not None
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]

View file

@ -15,6 +15,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -23,7 +24,7 @@ pytestmark = pytest.mark.anyio
@requirement("completion:prompt-arg")
@requirement("completion:result-shape")
async def test_complete_prompt_argument(connect: Connect) -> None:
async def test_complete_prompt_argument(connect: Connect, unstamped: Unstamp) -> None:
"""Completing a prompt argument delivers the ref, argument name, and current value to the handler.
The returned values are filtered by the argument's value, proving the value reached the handler.
@ -44,13 +45,13 @@ async def test_complete_prompt_argument(connect: Connect) -> None:
PromptReference(name="code_review"), argument={"name": "language", "value": "py"}
)
assert result == snapshot(
assert unstamped(result) == snapshot(
CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False))
)
@requirement("completion:resource-template-arg")
async def test_complete_resource_template_variable(connect: Connect) -> None:
async def test_complete_resource_template_variable(connect: Connect, unstamped: Unstamp) -> None:
"""Completing a URI template variable delivers the template URI and variable name to the handler."""
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
@ -67,11 +68,11 @@ async def test_complete_resource_template_variable(connect: Connect) -> None:
argument={"name": "owner", "value": "model"},
)
assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
assert unstamped(result) == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"])))
@requirement("completion:context-arguments")
async def test_complete_receives_context_arguments(connect: Connect) -> None:
async def test_complete_receives_context_arguments(connect: Connect, unstamped: Unstamp) -> None:
"""Previously-resolved arguments passed as completion context reach the handler.
The returned value is derived from the context, proving it arrived.
@ -92,7 +93,9 @@ async def test_complete_receives_context_arguments(connect: Connect) -> None:
context_arguments={"owner": "modelcontextprotocol"},
)
assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])))
assert unstamped(result) == snapshot(
CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"]))
)
@requirement("completion:error:invalid-ref")

View file

@ -32,6 +32,7 @@ from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
@ -53,7 +54,9 @@ def _list_tools(*names: str) -> ListToolsHandler:
@requirement("flow:tool-result:resource-link-follow")
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(
connect: Connect, unstamped: Unstamp
) -> None:
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the
@ -78,8 +81,10 @@ async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(conn
assert isinstance(link, ResourceLink)
read = await client.read_resource(link.uri)
assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]))
assert read == snapshot(
assert unstamped(called) == snapshot(
CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")])
)
assert unstamped(read) == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
)

View file

@ -11,6 +11,7 @@ from inline_snapshot import snapshot
from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -46,7 +47,7 @@ async def test_set_logging_level_reaches_handler(connect: Connect) -> None:
@requirement("logging:message:fields")
@requirement("tools:call:logging-mid-execution")
async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None:
async def test_log_messages_reach_logging_callback_in_order(connect: Connect, unstamped: Unstamp) -> None:
"""Log messages sent during a tool call arrive at the logging callback, in order, before the call returns.
The two messages pin the full notification shape: severity, optional logger name, and both
@ -83,7 +84,7 @@ async def test_log_messages_reach_logging_callback_in_order(connect: Connect) ->
async with connect(server, logging_callback=collect) as client:
result = await client.call_tool("chatty", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="done")]))
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")]))
assert received == snapshot(
[
LoggingMessageNotificationParams(level="info", logger="app.lifecycle", data="starting up"),

View file

@ -2,7 +2,8 @@
Meta is opaque pass-through data, so these tests assert identity against the value that was sent
rather than snapshotting a literal: the expected value and the sent value are the same variable,
which also proves the SDK injected nothing alongside it.
which also proves the SDK injected nothing alongside it beyond the 2026-era serverInfo stamp,
stripped via `unstamped` before comparison.
"""
import mcp_types as types
@ -10,6 +11,7 @@ import pytest
from mcp_types import CallToolResult, RequestParamsMeta, TextContent
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -42,7 +44,7 @@ async def test_request_meta_reaches_handler(connect: Connect) -> None:
@requirement("meta:result-to-client")
async def test_result_meta_reaches_client(connect: Connect) -> None:
async def test_result_meta_reaches_client(connect: Connect, unstamped: Unstamp) -> None:
"""The _meta object a handler attaches to its result is delivered to the client unchanged."""
result_meta = {"example.com/cost": 3}
@ -60,4 +62,4 @@ async def test_result_meta_reaches_client(connect: Connect) -> None:
async with connect(server) as client:
result = await client.call_tool("metered", {})
assert result == CallToolResult(content=[TextContent(text="done")], _meta=result_meta)
assert unstamped(result) == CallToolResult(content=[TextContent(text="done")], _meta=result_meta)

View file

@ -22,6 +22,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -29,7 +30,7 @@ pytestmark = pytest.mark.anyio
@requirement("tools:list:pagination")
async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None:
async def test_next_cursor_round_trips_through_the_client(connect: Connect, unstamped: Unstamp) -> None:
"""The next_cursor a list handler returns reaches the client, and the cursor the client sends
back on the following call reaches the handler verbatim.
"""
@ -55,7 +56,9 @@ async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> N
assert first_page.next_cursor == cursor
assert seen_cursors == [None, cursor]
assert [tool.name for tool in first_page.tools] == ["alpha"]
assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
assert unstamped(second_page) == snapshot(
ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])
)
@requirement("pagination:exhaustion")

View file

@ -18,6 +18,7 @@ from mcp_types import CallToolResult, ProgressNotification, ProgressNotification
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from mcp.shared.session import ProgressFnT
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
@ -27,7 +28,7 @@ pytestmark = pytest.mark.anyio
@requirement("protocol:progress:callback")
@requirement("tools:call:progress")
async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None:
async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect, unstamped: Unstamp) -> None:
"""Progress notifications emitted by a tool handler reach the caller's progress callback in order."""
received: list[tuple[float, float | None, str | None]] = []
@ -51,7 +52,7 @@ async def test_progress_during_tool_call_reaches_callback_in_order(connect: Conn
async with connect(server) as client:
result = await client.call_tool("download", {}, progress_callback=collect)
assert result == snapshot(CallToolResult(content=[TextContent(text="downloaded")]))
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="downloaded")]))
assert received == snapshot([(1.0, 3.0, "first chunk"), (2.0, 3.0, "second chunk"), (3.0, 3.0, "done")])
@ -83,7 +84,7 @@ async def test_progress_token_visible_to_handler(connect: Connect) -> None:
@requirement("protocol:progress:no-token")
async def test_no_progress_callback_means_no_token(connect: Connect) -> None:
async def test_no_progress_callback_means_no_token(connect: Connect, unstamped: Unstamp) -> None:
"""Without a progress callback the request carries no progress token.
The low-level API has no way to report request-scoped progress without a token, so a handler
@ -105,7 +106,7 @@ async def test_no_progress_callback_means_no_token(connect: Connect) -> None:
async with connect(server) as client:
result = await client.call_tool("inspect", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="None")]))
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="None")]))
@requirement("protocol:progress:client-to-server")

View file

@ -21,6 +21,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -28,7 +29,7 @@ pytestmark = pytest.mark.anyio
@requirement("prompts:list:basic")
async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None:
async def test_list_prompts_returns_registered_prompts(connect: Connect, unstamped: Unstamp) -> None:
"""The prompts returned by the handler reach the client with their argument declarations intact."""
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
@ -52,7 +53,7 @@ async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None
async with connect(server) as client:
result = await client.list_prompts()
assert result == snapshot(
assert unstamped(result) == snapshot(
ListPromptsResult(
prompts=[
Prompt(
@ -71,7 +72,7 @@ async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None
@requirement("prompts:get:with-args")
async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
async def test_get_prompt_substitutes_arguments(connect: Connect, unstamped: Unstamp) -> None:
"""Arguments supplied by the client reach the prompt handler; the templated message comes back."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@ -87,7 +88,7 @@ async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
async with connect(server) as client:
result = await client.get_prompt("greet", {"name": "Ada"})
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(
description="A personalised greeting.",
messages=[PromptMessage(role="user", content=TextContent(text="Hello, Ada!"))],
@ -96,7 +97,7 @@ async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
@requirement("prompts:get:multi-message")
async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None:
async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect, unstamped: Unstamp) -> None:
"""A prompt returning a user/assistant conversation reaches the client with roles and order intact."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@ -114,7 +115,7 @@ async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Co
async with connect(server) as client:
result = await client.get_prompt("geography_quiz")
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=TextContent(text="What is the capital of France?")),
@ -126,7 +127,7 @@ async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Co
@requirement("prompts:get:no-args")
async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None:
async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect, unstamped: Unstamp) -> None:
"""A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages."""
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@ -139,7 +140,7 @@ async def test_get_prompt_without_arguments_returns_the_messages(connect: Connec
async with connect(server) as client:
result = await client.get_prompt("static")
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))])
)
@ -147,7 +148,7 @@ async def test_get_prompt_without_arguments_returns_the_messages(connect: Connec
@requirement("prompts:get:content:image")
@requirement("prompts:get:content:audio")
@requirement("prompts:get:content:embedded-resource")
async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None:
async def test_get_prompt_with_non_text_content_round_trips(connect: Connect, unstamped: Unstamp) -> None:
"""Prompt messages can carry image, audio, and embedded-resource content; all reach the client.
A single full-result snapshot proves all three content types round-trip: each block in the result
@ -175,7 +176,7 @@ async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) ->
async with connect(server) as client:
result = await client.get_prompt("media", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
GetPromptResult(
messages=[
PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")),

View file

@ -27,6 +27,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
@ -36,7 +37,7 @@ pytestmark = pytest.mark.anyio
@requirement("resources:list:basic")
@requirement("resources:annotations")
async def test_list_resources_returns_registered_resources(connect: Connect) -> None:
async def test_list_resources_returns_registered_resources(connect: Connect, unstamped: Unstamp) -> None:
"""Listed resources reach the client with their URIs, names, and optional descriptive fields intact.
The fully-populated entry includes annotations, so the snapshot also proves they round-trip.
@ -71,7 +72,7 @@ async def test_list_resources_returns_registered_resources(connect: Connect) ->
async with connect(server) as client:
result = await client.list_resources()
assert result == snapshot(
assert unstamped(result) == snapshot(
ListResourcesResult(
resources=[
Resource(uri="memo://minimal", name="minimal"),
@ -93,7 +94,7 @@ async def test_list_resources_returns_registered_resources(connect: Connect) ->
@requirement("resources:read:text")
async def test_read_resource_text(connect: Connect) -> None:
async def test_read_resource_text(connect: Connect, unstamped: Unstamp) -> None:
"""Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler."""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
@ -106,7 +107,7 @@ async def test_read_resource_text(connect: Connect) -> None:
async with connect(server) as client:
result = await client.read_resource("file:///greeting.txt")
assert result == snapshot(
assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")]
)
@ -114,7 +115,7 @@ async def test_read_resource_text(connect: Connect) -> None:
@requirement("resources:read:blob")
async def test_read_resource_binary(connect: Connect) -> None:
async def test_read_resource_binary(connect: Connect, unstamped: Unstamp) -> None:
"""Reading a binary resource returns its contents base64-encoded in the blob field."""
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
@ -133,7 +134,7 @@ async def test_read_resource_binary(connect: Connect) -> None:
async with connect(server) as client:
result = await client.read_resource("file:///pixel.png")
assert result == snapshot(
assert unstamped(result) == snapshot(
ReadResourceResult(
contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")]
)
@ -161,7 +162,7 @@ async def test_read_resource_unknown_uri_is_protocol_error(connect: Connect) ->
@requirement("resources:templates:list")
async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None:
async def test_list_resource_templates_returns_registered_templates(connect: Connect, unstamped: Unstamp) -> None:
"""Listed resource templates reach the client with their URI templates and descriptive fields intact."""
async def list_resource_templates(
@ -186,7 +187,7 @@ async def test_list_resource_templates_returns_registered_templates(connect: Con
async with connect(server) as client:
result = await client.list_resource_templates()
assert result == snapshot(
assert unstamped(result) == snapshot(
ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(uri_template="users://{user_id}", name="user"),

View file

@ -22,6 +22,7 @@ from mcp_types import (
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
@ -29,7 +30,7 @@ pytestmark = pytest.mark.anyio
@requirement("tools:call:content:text")
async def test_call_tool_returns_text_content(connect: Connect) -> None:
async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None:
"""Arguments reach the tool handler; its content comes back as the call result."""
async def list_tools(
@ -49,11 +50,11 @@ async def test_call_tool_returns_text_content(connect: Connect) -> None:
async with connect(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="5")]))
@requirement("tools:call:is-error")
async def test_call_tool_execution_error_is_returned_as_result(connect: Connect) -> None:
async def test_call_tool_execution_error_is_returned_as_result(connect: Connect, unstamped: Unstamp) -> None:
"""A tool reporting its own failure with is_error=True reaches the client as a result, not an exception.
Tool execution errors are part of the result so the caller (typically a model) can see
@ -69,7 +70,7 @@ async def test_call_tool_execution_error_is_returned_as_result(connect: Connect)
async with connect(server) as client:
result = await client.call_tool("flux", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
)
@ -117,7 +118,7 @@ async def test_call_tool_uncaught_exception_becomes_error_response(connect: Conn
@requirement("tools:list:basic")
async def test_list_tools_returns_registered_tools(connect: Connect) -> None:
async def test_list_tools_returns_registered_tools(connect: Connect, unstamped: Unstamp) -> None:
"""The tools advertised by the server's list handler arrive at the client unchanged."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
@ -141,7 +142,7 @@ async def test_list_tools_returns_registered_tools(connect: Connect) -> None:
async with connect(server) as client:
result = await client.list_tools()
assert result == snapshot(
assert unstamped(result) == snapshot(
ListToolsResult(
tools=[
Tool(
@ -163,7 +164,7 @@ async def test_list_tools_returns_registered_tools(connect: Connect) -> None:
@requirement("tools:input-schema:preserve-additional-properties")
@requirement("tools:input-schema:preserve-defs")
@requirement("tools:input-schema:preserve-schema-dialect")
async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect) -> None:
async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect, unstamped: Unstamp) -> None:
"""A rich JSON Schema 2020-12 inputSchema reaches the client unchanged and the tool is callable.
The single identity assertion below proves all four pass-through behaviours at once: the same
@ -202,11 +203,11 @@ async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Con
called = await client.call_tool("typed", {"count": 3, "options": {"verbose": True}})
assert listed.tools[0].input_schema == schema
assert called == snapshot(CallToolResult(content=[TextContent(text="ok")]))
assert unstamped(called) == snapshot(CallToolResult(content=[TextContent(text="ok")]))
@requirement("tools:list:metadata")
async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
async def test_list_tools_optional_fields_round_trip(connect: Connect, unstamped: Unstamp) -> None:
"""Every optional Tool field the server supplies reaches the client unchanged."""
tool = Tool(
@ -228,7 +229,7 @@ async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
async with connect(server) as client:
result = await client.list_tools()
assert result == snapshot(
assert unstamped(result) == snapshot(
ListToolsResult(
tools=[
Tool(
@ -251,7 +252,7 @@ async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
@requirement("tools:call:content:audio")
@requirement("tools:call:content:resource-link")
@requirement("tools:call:content:embedded-resource")
async def test_call_tool_multiple_content_block_types(connect: Connect) -> None:
async def test_call_tool_multiple_content_block_types(connect: Connect, unstamped: Unstamp) -> None:
"""A tool result can mix every content block type; all of them arrive in order.
The payloads are tiny fixed base64 strings ("aW1n" is b"img", "YXVk" is b"aud") so the
@ -280,7 +281,7 @@ async def test_call_tool_multiple_content_block_types(connect: Connect) -> None:
async with connect(server) as client:
result = await client.call_tool("render", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(
content=[
TextContent(text="all five content block types"),
@ -296,7 +297,7 @@ async def test_call_tool_multiple_content_block_types(connect: Connect) -> None:
@requirement("tools:call:structured-content")
async def test_call_tool_structured_content(connect: Connect) -> None:
async def test_call_tool_structured_content(connect: Connect, unstamped: Unstamp) -> None:
"""A tool result carrying structured content alongside content delivers both to the client."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
@ -311,11 +312,13 @@ async def test_call_tool_structured_content(connect: Connect) -> None:
async with connect(server) as client:
result = await client.call_tool("sum", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}))
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})
)
@requirement("tools:call:concurrent")
async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> None:
async def test_concurrent_tool_calls_complete_independently(connect: Connect, unstamped: Unstamp) -> None:
"""Two tool calls in flight at once run concurrently and each caller gets its own answer.
Both handlers are held on a shared event after signalling that they have started, and the test
@ -347,7 +350,7 @@ async def test_concurrent_tool_calls_complete_independently(connect: Connect) ->
async with anyio.create_task_group() as task_group: # pragma: no branch
async def call_and_record(tag: str) -> None:
results[tag] = await client.call_tool("echo", {"tag": tag})
results[tag] = unstamped(await client.call_tool("echo", {"tag": tag}))
task_group.start_soon(call_and_record, "first")
task_group.start_soon(call_and_record, "second")
@ -403,7 +406,7 @@ async def test_call_tool_structured_content_violating_output_schema_is_rejected_
@requirement("client:output-schema:skip-on-error")
async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> None:
async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect, unstamped: Unstamp) -> None:
"""A tool result with isError true is returned as-is even when its structured content violates the schema.
The schema is cached up front so the client could validate, proving the bypass is specifically the
@ -437,7 +440,7 @@ async def test_is_error_result_bypasses_client_output_schema_validation(connect:
await client.list_tools()
result = await client.call_tool("forecast", {})
assert result == snapshot(
assert unstamped(result) == snapshot(
CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True)
)
@ -475,7 +478,9 @@ async def test_declared_output_schema_with_no_structured_content_is_rejected_by_
@requirement("client:output-schema:auto-list")
async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> None:
async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(
connect: Connect, unstamped: Unstamp
) -> None:
"""Calling a tool whose schema is not cached issues exactly one implicit tools/list to populate it.
The first call_tool of an uncached tool triggers a tools/list the caller never asked for; the
@ -509,5 +514,7 @@ async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools
second = await client.call_tool("forecast", {})
assert list_calls == ["called"]
assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}))
assert second == first
assert unstamped(first) == snapshot(
CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})
)
assert unstamped(second) == first