Skip to content

Commit 7155b20

Browse files
committed
Allow ToolError to carry content for is_error results
Fixes #348
1 parent 77001c9 commit 7155b20

4 files changed

Lines changed: 63 additions & 1 deletion

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,18 @@ class ToolError(MCPServerError):
5555
The SDK raises it too, for an unknown tool name and for arguments that fail
5656
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
5757
around `MCPServer.call_tool()` catches every tool failure, crash or not.
58+
59+
Args:
60+
message: The error message for the model to read.
61+
content: Optional tool result content to include in the error response.
62+
When provided, these content blocks are returned with `is_error=True`
63+
instead of a single text block derived from `message`.
5864
"""
5965

66+
def __init__(self, message: str = "", *args: object, content: object = None) -> None:
67+
super().__init__(message, *args)
68+
self.content = content
69+
6070

6171
class UnexpectedToolError(ToolError):
6272
"""A tool call failed with something other than `ToolError`, `ResourceError`, or `MCPError`.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,17 @@ async def _handle_call_tool(
453453
logger.info("Tool %r failed: %r", params.name, str(exc))
454454
else:
455455
logger.exception("Tool %r raised an unexpected exception", params.name)
456+
if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError) and getattr(exc, "content", None) is not None:
457+
from mcp.server.mcpserver.utilities.func_metadata import _convert_to_content
458+
459+
raw = exc.content
460+
if isinstance(raw, (list, tuple)):
461+
blocks: list[Any] = []
462+
for item in raw:
463+
blocks.extend(_convert_to_content(item))
464+
else:
465+
blocks = _convert_to_content(raw)
466+
return CallToolResult(content=blocks, is_error=True)
456467
return CallToolResult(content=[TextContent(type="text", text=str(exc))], is_error=True)
457468

458469
async def _handle_list_resources(

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,10 @@ async def run(
209209
raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc
210210
except (ToolError, ResourceError) as exc:
211211
# Raised deliberately by the tool, a resolver, or a resource it read.
212-
raise ToolError(f"Error executing tool {self.name}: {exc}") from exc
212+
wrapped = ToolError(f"Error executing tool {self.name}: {exc}")
213+
if isinstance(exc, ToolError) and getattr(exc, "content", None) is not None:
214+
wrapped.content = exc.content
215+
raise wrapped from exc
213216
except Exception as exc:
214217
# A crash: the exception's own text stays on the server.
215218
raise UnexpectedToolError(f"Error executing tool {self.name}") from exc

‎tests/server/mcpserver/test_server.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,6 +2562,44 @@ def spend() -> str:
25622562
)
25632563

25642564

2565+
2566+
async def test_tool_error_with_content_returns_content_in_error_result():
2567+
"""ToolError with content returns that content in the is_error result."""
2568+
mcp = MCPServer()
2569+
2570+
@mcp.tool()
2571+
def analyze() -> str:
2572+
raise ToolError(
2573+
"analysis failed",
2574+
content=[TextContent(type="text", text="partial output"), ImageContent(type="image", data="YWJj", mime_type="image/png")],
2575+
)
2576+
2577+
async with Client(mcp) as client:
2578+
result = await client.call_tool("analyze", {})
2579+
2580+
assert result.is_error is True
2581+
assert len(result.content) == 2
2582+
assert result.content[0] == TextContent(type="text", text="partial output")
2583+
assert result.content[1] == ImageContent(type="image", data="YWJj", mime_type="image/png")
2584+
2585+
2586+
async def test_tool_error_with_image_helper_content_returns_image_in_error_result():
2587+
"""ToolError with Image helper content converts to ImageContent in is_error result."""
2588+
mcp = MCPServer()
2589+
2590+
@mcp.tool()
2591+
def screenshot() -> str:
2592+
raise ToolError("capture failed", content=[Image(data=b"abc", format="png")])
2593+
2594+
async with Client(mcp) as client:
2595+
result = await client.call_tool("screenshot", {})
2596+
2597+
assert result.is_error is True
2598+
assert len(result.content) == 1
2599+
assert result.content[0].type == "image"
2600+
assert result.content[0].mime_type == "image/png"
2601+
2602+
25652603
async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback(
25662604
caplog: pytest.LogCaptureFixture,
25672605
):

0 commit comments

Comments
 (0)