This commit is contained in:
parent
3a6f2996cd
commit
837ef904f8
11 changed files with 1757 additions and 567 deletions
|
|
@ -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")])]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue