Skip to content

Commit b396a03

Browse files
LUbaoshuaiLUbaoshuai
authored andcommitted
feat(server): carry structured input-validation details in tool error results
When MCPServer rejects arguments against a tool's input schema, the CallToolResult flattened pydantic's ValidationError into a single text line, so clients had to regex prose to tell a wrong type from a missing required field (#3351). Attach structured_content to that specific error result: a stable input_validation code plus per-error path/type/message. The rejected values stay out of the payload, the text content the model reads is unchanged, and clients only revalidate structured content of successful results, so no output-schema check is affected.
1 parent d2290ca commit b396a03

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,27 @@ async def _handle_call_tool(
437437
if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError):
438438
if isinstance(exc.__cause__, ValidationError):
439439
# Field names only: the rejected values are the caller's data.
440-
fields = sorted({".".join(str(part) for part in err["loc"]) for err in exc.__cause__.errors()})
440+
errors = exc.__cause__.errors()
441+
fields = sorted({".".join(str(part) for part in err["loc"]) for err in errors})
441442
logger.info("Tool %r rejected arguments: %r", params.name, fields)
443+
# Clients can only revalidate structured content of successful
444+
# results, so error results are free to carry machine-readable
445+
# details here without tripping any output schema check.
446+
return CallToolResult(
447+
content=[TextContent(type="text", text=str(exc))],
448+
structured_content={
449+
"type": "input_validation",
450+
"errors": [
451+
{
452+
"path": ".".join(str(part) for part in err.get("loc", ())),
453+
"type": err.get("type"),
454+
"message": err.get("msg"),
455+
}
456+
for err in errors
457+
],
458+
},
459+
is_error=True,
460+
)
442461
else:
443462
# %r keeps peer-supplied text on one line.
444463
logger.info("Tool %r failed: %r", params.name, str(exc))

‎tests/server/mcpserver/test_server.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,46 @@ async def test_tool_error_details(self):
325325
assert content.text == "Error executing tool error_tool_fn"
326326
assert result.is_error is True
327327

328+
async def test_input_validation_error_carries_structured_details(self):
329+
"""An input-schema failure carries machine-readable details in structured content (issue #3351)."""
330+
331+
def takes_int(x: int) -> str:
332+
return str(x)
333+
334+
mcp = MCPServer()
335+
mcp.add_tool(takes_int)
336+
async with Client(mcp) as client:
337+
result = await client.call_tool("takes_int", {"x": "not-a-number"})
338+
339+
assert result.is_error is True
340+
# The text content the model reads is unchanged.
341+
assert isinstance(result.content[0], TextContent)
342+
assert "Error executing tool takes_int" in result.content[0].text
343+
344+
structured = result.structured_content
345+
assert structured is not None
346+
assert structured["type"] == "input_validation"
347+
assert len(structured["errors"]) == 1
348+
assert structured["errors"][0]["path"] == "x"
349+
assert structured["errors"][0]["type"] == "int_parsing"
350+
assert isinstance(structured["errors"][0]["message"], str)
351+
# The rejected value is the caller's data and is never echoed back.
352+
assert "not-a-number" not in str(structured)
353+
354+
async def test_deliberate_tool_error_has_no_structured_details(self):
355+
"""Only SDK-generated input-validation failures carry structured details."""
356+
357+
def failing_tool() -> None:
358+
raise ToolError("deliberate failure")
359+
360+
mcp = MCPServer()
361+
mcp.add_tool(failing_tool)
362+
async with Client(mcp) as client:
363+
result = await client.call_tool("failing_tool", {})
364+
365+
assert result.is_error is True
366+
assert result.structured_content is None
367+
328368
async def test_tool_return_value_conversion(self):
329369
mcp = MCPServer()
330370
mcp.add_tool(tool_fn)

0 commit comments

Comments
 (0)