chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
"""Tool interactions against the low-level Server, driven through the public Client API."""
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
INVALID_PARAMS,
|
||||
AudioContent,
|
||||
CallToolResult,
|
||||
EmbeddedResource,
|
||||
ErrorData,
|
||||
Icon,
|
||||
ImageContent,
|
||||
ListToolsResult,
|
||||
ResourceLink,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
Tool,
|
||||
ToolAnnotations,
|
||||
)
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("tools:call:content:text")
|
||||
async def test_call_tool_returns_text_content(connect: Connect) -> None:
|
||||
"""Arguments reach the tool handler; its content comes back as the call result."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "add"
|
||||
assert params.arguments is not None
|
||||
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
|
||||
|
||||
server = Server("adder", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.call_tool("add", {"a": 2, "b": 3})
|
||||
|
||||
assert 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:
|
||||
"""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
|
||||
them; only protocol-level failures become JSON-RPC errors.
|
||||
"""
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "flux"
|
||||
return CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
|
||||
|
||||
server = Server("errors", on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.call_tool("flux", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True)
|
||||
)
|
||||
|
||||
|
||||
@requirement("tools:call:unknown-name")
|
||||
async def test_call_tool_unknown_tool_is_protocol_error(connect: Connect) -> None:
|
||||
"""A handler that rejects an unrecognised tool name with MCPError produces a JSON-RPC error.
|
||||
|
||||
The error's code, message, and data chosen by the handler reach the client verbatim.
|
||||
"""
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {params.name}", data={"requested": params.name})
|
||||
|
||||
server = Server("errors", on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("nope", {})
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(code=INVALID_PARAMS, message="Unknown tool: nope", data={"requested": "nope"})
|
||||
)
|
||||
|
||||
|
||||
@requirement("protocol:error:internal-error")
|
||||
async def test_call_tool_uncaught_exception_becomes_error_response(connect: Connect) -> None:
|
||||
"""An uncaught exception in the tool handler surfaces to the client as a JSON-RPC error.
|
||||
|
||||
The low-level server reports it with code 0 and the exception text as the message; see the
|
||||
divergence note on the requirement.
|
||||
"""
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "explode"
|
||||
raise ValueError("boom")
|
||||
|
||||
server = Server("errors", on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("explode", {})
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=0, message="boom"))
|
||||
|
||||
|
||||
@requirement("tools:list:basic")
|
||||
async def test_list_tools_returns_registered_tools(connect: Connect) -> 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:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="add",
|
||||
description="Add two integers.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
),
|
||||
Tool(name="reset", description="Reset the calculator.", input_schema={"type": "object"}),
|
||||
]
|
||||
)
|
||||
|
||||
server = Server("calculator", on_list_tools=list_tools)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.list_tools()
|
||||
|
||||
assert result == snapshot(
|
||||
ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="add",
|
||||
description="Add two integers.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
),
|
||||
Tool(name="reset", description="Reset the calculator.", input_schema={"type": "object"}),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("tools:input-schema:json-schema-2020-12")
|
||||
@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:
|
||||
"""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
|
||||
dict literal that was registered is the dict that arrives, so $schema, $defs, the nested object
|
||||
property, and additionalProperties are each preserved by virtue of the whole schema being
|
||||
preserved. The follow-up call proves the rich-schema tool is callable end to end.
|
||||
"""
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"$defs": {"positive": {"type": "integer", "exclusiveMinimum": 0}},
|
||||
"properties": {
|
||||
"count": {"$ref": "#/$defs/positive"},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"properties": {"verbose": {"type": "boolean"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"required": ["count"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name="typed", input_schema=schema)])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "typed"
|
||||
assert params.arguments == {"count": 3, "options": {"verbose": True}}
|
||||
return CallToolResult(content=[TextContent(text="ok")])
|
||||
|
||||
server = Server("typed", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
listed = await client.list_tools()
|
||||
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")]))
|
||||
|
||||
|
||||
@requirement("tools:list:metadata")
|
||||
async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None:
|
||||
"""Every optional Tool field the server supplies reaches the client unchanged."""
|
||||
|
||||
tool = Tool(
|
||||
name="annotated",
|
||||
title="Annotated tool",
|
||||
description="A tool carrying every optional field.",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object", "properties": {"answer": {"type": "integer"}}},
|
||||
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
|
||||
annotations=ToolAnnotations(title="Display title", read_only_hint=True, idempotent_hint=True),
|
||||
_meta={"example.com/source": "interaction-suite"},
|
||||
)
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[tool])
|
||||
|
||||
server = Server("annotated", on_list_tools=list_tools)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.list_tools()
|
||||
|
||||
assert result == snapshot(
|
||||
ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="annotated",
|
||||
title="Annotated tool",
|
||||
description="A tool carrying every optional field.",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object", "properties": {"answer": {"type": "integer"}}},
|
||||
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
|
||||
annotations=ToolAnnotations(title="Display title", read_only_hint=True, idempotent_hint=True),
|
||||
_meta={"example.com/source": "interaction-suite"},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("tools:call:content:mixed")
|
||||
@requirement("tools:call:content:image")
|
||||
@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:
|
||||
"""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
|
||||
snapshot pins the exact bytes the client receives.
|
||||
"""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name="render", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "render"
|
||||
return CallToolResult(
|
||||
content=[
|
||||
TextContent(text="all five content block types"),
|
||||
ImageContent(data="aW1n", mime_type="image/png"),
|
||||
AudioContent(data="YXVk", mime_type="audio/wav"),
|
||||
ResourceLink(name="report", uri="resource://reports/1", description="The full report"),
|
||||
EmbeddedResource(
|
||||
resource=TextResourceContents(uri="resource://reports/1", mime_type="text/plain", text="contents")
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
server = Server("renderer", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.call_tool("render", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[
|
||||
TextContent(text="all five content block types"),
|
||||
ImageContent(data="aW1n", mime_type="image/png"),
|
||||
AudioContent(data="YXVk", mime_type="audio/wav"),
|
||||
ResourceLink(name="report", uri="resource://reports/1", description="The full report"),
|
||||
EmbeddedResource(
|
||||
resource=TextResourceContents(uri="resource://reports/1", mime_type="text/plain", text="contents")
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("tools:call:structured-content")
|
||||
async def test_call_tool_structured_content(connect: Connect) -> 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:
|
||||
return ListToolsResult(tools=[Tool(name="sum", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "sum"
|
||||
return CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})
|
||||
|
||||
server = Server("calculator", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
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}))
|
||||
|
||||
|
||||
@requirement("tools:call:concurrent")
|
||||
async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> 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
|
||||
only releases them once both signals have arrived -- a server that processed requests
|
||||
sequentially would never start the second handler and the test would time out instead.
|
||||
"""
|
||||
started: list[str] = []
|
||||
started_events = {"first": anyio.Event(), "second": anyio.Event()}
|
||||
release = anyio.Event()
|
||||
results: dict[str, CallToolResult] = {}
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "echo"
|
||||
assert params.arguments is not None
|
||||
tag = params.arguments["tag"]
|
||||
assert isinstance(tag, str)
|
||||
started.append(tag)
|
||||
started_events[tag].set()
|
||||
await release.wait()
|
||||
return CallToolResult(content=[TextContent(text=tag)])
|
||||
|
||||
server = Server("echoer", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
with anyio.fail_after(5):
|
||||
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})
|
||||
|
||||
task_group.start_soon(call_and_record, "first")
|
||||
task_group.start_soon(call_and_record, "second")
|
||||
|
||||
# Both handlers are running at the same time before either is allowed to finish.
|
||||
await started_events["first"].wait()
|
||||
await started_events["second"].wait()
|
||||
release.set()
|
||||
|
||||
assert sorted(started) == ["first", "second"]
|
||||
assert results == snapshot(
|
||||
{
|
||||
"first": CallToolResult(content=[TextContent(text="first")]),
|
||||
"second": CallToolResult(content=[TextContent(text="second")]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@requirement("client:output-schema:validate")
|
||||
async def test_call_tool_structured_content_violating_output_schema_is_rejected_by_the_client(connect: Connect) -> None:
|
||||
"""A result whose structured content does not conform to the tool's declared output schema never
|
||||
reaches the caller: the client validates it against the schema cached from tools/list and raises.
|
||||
"""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="forecast",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"temperature": {"type": "number"}},
|
||||
"required": ["temperature"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "forecast"
|
||||
return CallToolResult(content=[TextContent(text="warm")], structured_content={"temperature": "warm"})
|
||||
|
||||
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
await client.list_tools()
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("forecast", {})
|
||||
|
||||
# The message embeds the jsonschema validation error, so only the SDK-authored prefix is pinned.
|
||||
assert str(exc_info.value).startswith("Invalid structured content returned by tool forecast")
|
||||
|
||||
|
||||
@requirement("client:output-schema:skip-on-error")
|
||||
async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> 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
|
||||
isError flag and not an empty cache.
|
||||
"""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="forecast",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"temperature": {"type": "number"}},
|
||||
"required": ["temperature"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "forecast"
|
||||
return CallToolResult(
|
||||
content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True
|
||||
)
|
||||
|
||||
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("forecast", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True)
|
||||
)
|
||||
|
||||
|
||||
@requirement("client:output-schema:missing-structured")
|
||||
async def test_declared_output_schema_with_no_structured_content_is_rejected_by_the_client(connect: Connect) -> None:
|
||||
"""A tool that declared an output schema but returned no structuredContent fails the client-side check.
|
||||
|
||||
The error is the SDK's own message, so the full text is snapshotted.
|
||||
"""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="forecast",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object", "properties": {"temperature": {"type": "number"}}},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "forecast"
|
||||
return CallToolResult(content=[TextContent(text="warm")])
|
||||
|
||||
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
await client.list_tools()
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("forecast", {})
|
||||
|
||||
assert str(exc_info.value) == snapshot("Tool forecast has an output schema but did not return structured content")
|
||||
|
||||
|
||||
@requirement("client:output-schema:auto-list")
|
||||
async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> 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
|
||||
second call hits the cache and does not. This is the SDK's chosen cache strategy and the cause of
|
||||
the surprising behaviour where a server with only on_call_tool sees a successful call answered
|
||||
with METHOD_NOT_FOUND from a request the caller never made; see the divergence on the requirement.
|
||||
"""
|
||||
list_calls: list[str] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
list_calls.append("called")
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="forecast",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object", "properties": {"temperature": {"type": "number"}}},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "forecast"
|
||||
return CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})
|
||||
|
||||
server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server) as client:
|
||||
first = await client.call_tool("forecast", {})
|
||||
assert list_calls == ["called"]
|
||||
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
|
||||
Reference in New Issue
Block a user