49b9bb6724
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
514 lines
22 KiB
Python
514 lines
22 KiB
Python
"""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
|