diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 5e831b1238..25b5ae22f9 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -55,8 +55,18 @@ class ToolError(MCPServerError): The SDK raises it too, for an unknown tool name and for arguments that fail the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError` around `MCPServer.call_tool()` catches every tool failure, crash or not. + + Args: + message: The error message for the model to read. + content: Optional tool result content to include in the error response. + When provided, these content blocks are returned with `is_error=True` + instead of a single text block derived from `message`. """ + def __init__(self, message: str = "", *args: object, content: object = None) -> None: + super().__init__(message, *args) + self.content = content + class UnexpectedToolError(ToolError): """A tool call failed with something other than `ToolError`, `ResourceError`, or `MCPError`. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7efa29108d..8bee6c78cc 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -453,6 +453,17 @@ async def _handle_call_tool( logger.info("Tool %r failed: %r", params.name, str(exc)) else: logger.exception("Tool %r raised an unexpected exception", params.name) + if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError) and getattr(exc, "content", None) is not None: + from mcp.server.mcpserver.utilities.func_metadata import _convert_to_content + + raw = exc.content + if isinstance(raw, (list, tuple)): + blocks: list[Any] = [] + for item in raw: + blocks.extend(_convert_to_content(item)) + else: + blocks = _convert_to_content(raw) + return CallToolResult(content=blocks, is_error=True) return CallToolResult(content=[TextContent(type="text", text=str(exc))], is_error=True) async def _handle_list_resources( diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 10a2d6c489..3ea8860026 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -209,7 +209,10 @@ async def run( raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc except (ToolError, ResourceError) as exc: # Raised deliberately by the tool, a resolver, or a resource it read. - raise ToolError(f"Error executing tool {self.name}: {exc}") from exc + wrapped = ToolError(f"Error executing tool {self.name}: {exc}") + if isinstance(exc, ToolError) and getattr(exc, "content", None) is not None: + wrapped.content = exc.content + raise wrapped from exc except Exception as exc: # A crash: the exception's own text stays on the server. raise UnexpectedToolError(f"Error executing tool {self.name}") from exc diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index a266714298..42ee6f09b0 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -2562,6 +2562,44 @@ def spend() -> str: ) + +async def test_tool_error_with_content_returns_content_in_error_result(): + """ToolError with content returns that content in the is_error result.""" + mcp = MCPServer() + + @mcp.tool() + def analyze() -> str: + raise ToolError( + "analysis failed", + content=[TextContent(type="text", text="partial output"), ImageContent(type="image", data="YWJj", mime_type="image/png")], + ) + + async with Client(mcp) as client: + result = await client.call_tool("analyze", {}) + + assert result.is_error is True + assert len(result.content) == 2 + assert result.content[0] == TextContent(type="text", text="partial output") + assert result.content[1] == ImageContent(type="image", data="YWJj", mime_type="image/png") + + +async def test_tool_error_with_image_helper_content_returns_image_in_error_result(): + """ToolError with Image helper content converts to ImageContent in is_error result.""" + mcp = MCPServer() + + @mcp.tool() + def screenshot() -> str: + raise ToolError("capture failed", content=[Image(data=b"abc", format="png")]) + + async with Client(mcp) as client: + result = await client.call_tool("screenshot", {}) + + assert result.is_error is True + assert len(result.content) == 1 + assert result.content[0].type == "image" + assert result.content[0].mime_type == "image/png" + + async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback( caplog: pytest.LogCaptureFixture, ):