diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 1f3e863dbd..3679ac8229 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -437,8 +437,27 @@ async def _handle_call_tool( if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError): if isinstance(exc.__cause__, ValidationError): # Field names only: the rejected values are the caller's data. - fields = sorted({".".join(str(part) for part in err["loc"]) for err in exc.__cause__.errors()}) + errors = exc.__cause__.errors() + fields = sorted({".".join(str(part) for part in err["loc"]) for err in errors}) logger.info("Tool %r rejected arguments: %r", params.name, fields) + # Clients can only revalidate structured content of successful + # results, so error results are free to carry machine-readable + # details here without tripping any output schema check. + return CallToolResult( + content=[TextContent(type="text", text=str(exc))], + structured_content={ + "type": "input_validation", + "errors": [ + { + "path": ".".join(str(part) for part in err.get("loc", ())), + "type": err.get("type"), + "message": err.get("msg"), + } + for err in errors + ], + }, + is_error=True, + ) else: # %r keeps peer-supplied text on one line. logger.info("Tool %r failed: %r", params.name, str(exc)) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 3f90ce1368..730a2f4c6c 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -325,6 +325,46 @@ async def test_tool_error_details(self): assert content.text == "Error executing tool error_tool_fn" assert result.is_error is True + async def test_input_validation_error_carries_structured_details(self): + """An input-schema failure carries machine-readable details in structured content (issue #3351).""" + + def takes_int(x: int) -> str: + return str(x) + + mcp = MCPServer() + mcp.add_tool(takes_int) + async with Client(mcp) as client: + result = await client.call_tool("takes_int", {"x": "not-a-number"}) + + assert result.is_error is True + # The text content the model reads is unchanged. + assert isinstance(result.content[0], TextContent) + assert "Error executing tool takes_int" in result.content[0].text + + structured = result.structured_content + assert structured is not None + assert structured["type"] == "input_validation" + assert len(structured["errors"]) == 1 + assert structured["errors"][0]["path"] == "x" + assert structured["errors"][0]["type"] == "int_parsing" + assert isinstance(structured["errors"][0]["message"], str) + # The rejected value is the caller's data and is never echoed back. + assert "not-a-number" not in str(structured) + + async def test_deliberate_tool_error_has_no_structured_details(self): + """Only SDK-generated input-validation failures carry structured details.""" + + def failing_tool() -> None: + raise ToolError("deliberate failure") + + mcp = MCPServer() + mcp.add_tool(failing_tool) + async with Client(mcp) as client: + result = await client.call_tool("failing_tool", {}) + + assert result.is_error is True + assert result.structured_content is None + async def test_tool_return_value_conversion(self): mcp = MCPServer() mcp.add_tool(tool_fn)