Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
40 changes: 40 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading