"""Tool interactions against MCPServer, driven through the public Client API.""" import logging from typing import Annotated, Literal import pytest from inline_snapshot import snapshot from mcp_types import ( URL_ELICITATION_REQUIRED, CallToolResult, ElicitRequestURLParams, ErrorData, LoggingMessageNotification, LoggingMessageNotificationParams, TextContent, ) from pydantic import BaseModel, Field from mcp import MCPError from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import UrlElicitationRequiredError from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage 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 function; its return value comes back as text content. MCPServer also derives an output schema from the return annotation and attaches the matching structuredContent to the result. """ mcp = MCPServer("adder") @mcp.tool() def add(a: int, b: int) -> str: return str(a + b) async with connect(mcp) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"})) @requirement("mcpserver:tool:schema-variants") async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None: """Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments. The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function body sees them, proving the generated input schema and validation pipeline cover non-trivial types. """ mcp = MCPServer("typed") class Point(BaseModel): x: int y: int @mcp.tool() def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Field(ge=1, le=10)]) -> str: assert isinstance(point, Point) return f"{mode} at ({point.x}, {point.y}) x{count}" async with connect(mcp) as client: result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5}) assert result == snapshot( CallToolResult( content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"} ) ) @requirement("mcpserver:tool:handler-throws") @requirement("mcpserver:output-schema:skip-on-error") async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None: """An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error. The function's `-> str` annotation gives the tool a derived output schema, but the error result is built before any schema validation runs, so no validation failure is layered on top of the original exception. """ mcp = MCPServer("errors") @mcp.tool() def explode() -> str: raise ValueError("boom") async with connect(mcp) as client: result = await client.call_tool("explode", {}) assert result == snapshot( CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True) ) @requirement("mcpserver:tool:handler-throws") async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None: """A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error.""" mcp = MCPServer("errors") @mcp.tool() def flux() -> str: raise ToolError("flux capacitor offline") async with connect(mcp) as client: result = await client.call_tool("flux", {}) assert result == snapshot( CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True) ) @requirement("mcpserver:tool:unknown-name") async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None: """Calling a tool name that was never registered is reported as an is_error result. The spec classifies unknown tools as a protocol error; see the divergence note on the requirement. """ mcp = MCPServer("errors") @mcp.tool() def add() -> None: """A registered tool; the test calls a different name.""" async with connect(mcp) as client: result = await client.call_tool("nope", {}) assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True)) @requirement("mcpserver:tool:output-schema:model") @requirement("tools:call:structured-content:text-mirror") async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None: """A tool returning a pydantic model advertises the model's schema as the tool's output schema and returns the model's fields as structured content alongside a serialised text block. """ mcp = MCPServer("weather") class Weather(BaseModel): temperature: float conditions: str @mcp.tool() def get_weather() -> Weather: return Weather(temperature=22.5, conditions="sunny") async with connect(mcp) as client: listed = await client.list_tools() result = await client.call_tool("get_weather", {}) assert listed.tools[0].output_schema == snapshot( { "properties": { "temperature": {"title": "Temperature", "type": "number"}, "conditions": {"title": "Conditions", "type": "string"}, }, "required": ["temperature", "conditions"], "title": "Weather", "type": "object", } ) assert result == snapshot( CallToolResult( content=[ TextContent( text="""\ { "temperature": 22.5, "conditions": "sunny" }\ """ ) ], structured_content={"temperature": 22.5, "conditions": "sunny"}, ) ) @requirement("mcpserver:tool:output-schema:wrapped") async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None: """A tool returning a list wraps the value under a "result" key in both the generated output schema and the structured content. """ mcp = MCPServer("primes") @mcp.tool() def primes() -> list[int]: return [2, 3, 5] async with connect(mcp) as client: listed = await client.list_tools() result = await client.call_tool("primes", {}) assert listed.tools[0].output_schema == snapshot( { "properties": {"result": {"items": {"type": "integer"}, "title": "Result", "type": "array"}}, "required": ["result"], "title": "primesOutput", "type": "object", } ) assert result == snapshot( CallToolResult( content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")], structured_content={"result": [2, 3, 5]}, ) ) @requirement("mcpserver:tool:input-validation") async def test_call_tool_invalid_arguments_become_error_result(connect: Connect) -> None: """Arguments that fail validation against the tool's signature are reported as an is_error result describing the failure, not as a protocol error. """ mcp = MCPServer("adder") @mcp.tool() def add(a: int, b: int) -> str: """Validation rejects the arguments before the function is ever called.""" raise NotImplementedError async with connect(mcp) as client: result = await client.call_tool("add", {"b": 3}) # The description is raw pydantic output -- it embeds a pydantic-version-specific # errors.pydantic.dev URL and the internal `addArguments` model name -- so only the stable # prefix is asserted; a full snapshot would break on every pydantic upgrade. assert result.is_error is True assert isinstance(result.content[0], TextContent) assert result.content[0].text.startswith("Error executing tool add: 1 validation error") @requirement("mcpserver:output-schema:server-validate") @requirement("mcpserver:output-schema:missing-structured") async def test_tool_with_output_schema_returning_mismatched_structured_content_is_an_error_result( connect: Connect, ) -> None: """Structured content that fails the tool's own output schema is rejected on the server side. A tool annotated `Annotated[CallToolResult, Model]` returns a hand-built CallToolResult while declaring `Model` as its output schema; MCPServer validates the supplied structured_content against that schema before returning. The two cases -- a content shape that does not match, and no structured content at all -- both fail that validation and are reported as is_error results carrying the (raw pydantic) validation error wrapped in the SDK's stable prefix. """ mcp = MCPServer("forecaster") class Weather(BaseModel): temperature: float conditions: str @mcp.tool() def mismatched() -> Annotated[CallToolResult, Weather]: return CallToolResult(content=[TextContent(text="oops")], structured_content={"nope": True}) @mcp.tool() def missing() -> Annotated[CallToolResult, Weather]: return CallToolResult(content=[TextContent(text="oops")]) async with connect(mcp) as client: mismatched_result = await client.call_tool("mismatched", {}) missing_result = await client.call_tool("missing", {}) # The body of each message is raw pydantic ValidationError output (model name, field paths, # an errors.pydantic.dev URL) and changes across pydantic versions, so only the SDK's stable # prefix is asserted. assert mismatched_result.is_error is True assert isinstance(mismatched_result.content[0], TextContent) assert mismatched_result.content[0].text.startswith("Error executing tool mismatched: 2 validation errors") assert missing_result.is_error is True assert isinstance(missing_result.content[0], TextContent) assert missing_result.content[0].text.startswith("Error executing tool missing: 1 validation error") @requirement("mcpserver:tool:duplicate-name") async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None: """Registering a second tool with an already-used name keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning and discards the second registration (see the divergence note on the requirement). The second function is registered via add_tool with an explicit name so the test does not redefine the same function name in this scope. """ mcp = MCPServer("duplicates") @mcp.tool() def echo() -> str: return "first" def echo_second() -> str: """Passed to add_tool with a duplicate name; the registration is discarded so this never runs.""" raise NotImplementedError mcp.add_tool(echo_second, name="echo") async with connect(mcp) as client: listed = await client.list_tools() result = await client.call_tool("echo", {}) assert [tool.name for tool in listed.tools] == ["echo"] assert result == snapshot( CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"}) ) @requirement("mcpserver:tool:naming-validation") async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject( connect: Connect, caplog: pytest.LogCaptureFixture ) -> None: """A tool name that violates the SEP-986 rules logs a warning at registration but is still registered. The intended behaviour is rejection at registration time; MCPServer instead logs the naming-rule violation and proceeds (see the divergence note on the requirement). The warning spans several SDK-authored log records, so only the stable prefix and inclusion of the offending name are asserted. """ mcp = MCPServer("naming") with caplog.at_level(logging.WARNING, logger="mcp.shared.tool_name_validation"): @mcp.tool(name="bad name!") def bad() -> str: return "ok" assert any( rec.levelno == logging.WARNING and rec.message.startswith("Tool name validation warning") and "bad name!" in rec.message for rec in caplog.records ) async with connect(mcp) as client: listed = await client.list_tools() result = await client.call_tool("bad name!", {}) assert [tool.name for tool in listed.tools] == ["bad name!"] assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) @requirement("mcpserver:tool:url-elicitation-error") async def test_decorated_tool_raising_url_elicitation_required_surfaces_as_error_32042(connect: Connect) -> None: """A decorated tool raising the URL-elicitation-required error reaches the client as error -32042. MCPServer wraps every other tool exception as an is_error result; this error is special-cased so it propagates as the JSON-RPC error the client needs in order to present the listed URL interactions and retry the call. """ mcp = MCPServer("authorizer") @mcp.tool() def read_files() -> str: raise UrlElicitationRequiredError( [ ElicitRequestURLParams( message="Authorization required for your files.", url="https://example.com/oauth/authorize", elicitation_id="auth-001", ) ] ) async with connect(mcp) as client: with pytest.raises(MCPError) as exc_info: await client.call_tool("read_files", {}) assert exc_info.value.error.code == URL_ELICITATION_REQUIRED assert exc_info.value.error == snapshot( ErrorData( code=-32042, message="URL elicitation required", data={ "elicitations": [ { "mode": "url", "message": "Authorization required for your files.", "url": "https://example.com/oauth/authorize", "elicitationId": "auth-001", } ] }, ) ) @requirement("mcpserver:register:post-connect") async def test_adding_and_removing_tools_does_not_notify_connected_clients(connect: Connect) -> None: """Mutating the tool set on a running server changes tools/list but sends no notification. add_tool and remove_tool only update the registry: a connected client that listed the tools before the mutation has no way to learn it should list them again. The spec provides notifications/tools/list_changed for exactly this; MCPServer never sends it. The tool emits one log message as a sentinel so the test proves notifications do reach the collector -- the log message arrives, a list_changed does not. """ received: list[IncomingMessage] = [] mcp = MCPServer("mutable") def extra() -> str: """A tool registered at runtime; never called.""" raise NotImplementedError @mcp.tool() def doomed() -> str: """A tool removed at runtime; never called.""" raise NotImplementedError @mcp.tool() async def grow(ctx: Context) -> str: mcp.add_tool(extra, name="extra") mcp.remove_tool("doomed") await ctx.info("tool set changed") # pyright: ignore[reportDeprecated] return "mutated" async def collect(message: IncomingMessage) -> None: received.append(message) async with connect(mcp, message_handler=collect) as client: before = await client.list_tools() await client.call_tool("grow", {}) after = await client.list_tools() assert [tool.name for tool in before.tools] == ["doomed", "grow"] assert [tool.name for tool in after.tools] == ["grow", "extra"] assert received == snapshot( [LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="tool set changed"))] )