diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 5e831b1238..3b503f468a 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -1,5 +1,12 @@ """Custom exceptions for MCPServer.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp_types import ContentBlock + class MCPServerError(Exception): """Base error for MCPServer.""" @@ -44,19 +51,43 @@ class ToolError(MCPServerError): """A tool failure you anticipated. Raise this from a tool (or a resolver) for a failure you saw coming: the - call returns `is_error=True` with your message in `content` for the model to - read, and the server logs it at INFO without a traceback. A `ResourceError` - that escapes the tool (say from `ctx.read_resource()`) counts the same. Any - other exception bar `MCPError` (a protocol error) is treated as a crash: the - model sees only `Error executing tool `, and the server logs the - traceback at ERROR. Inside a pydantic validator, raise `ValueError` as pydantic - expects; it arrives as an argument-validation failure, which is anticipated too. + call returns ``is_error=True`` with your message in ``content`` for the model + to read, and the server logs it at INFO without a traceback. A + ``ResourceError`` that escapes the tool (say from ``ctx.read_resource()``) + counts the same. Any other exception bar ``MCPError`` (a protocol error) is + treated as a crash: the model sees only ``Error executing tool ``, and + the server logs the traceback at ERROR. Inside a pydantic validator, raise + ``ValueError`` as pydantic expects; it arrives as an argument-validation + failure, which is anticipated too. 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. + the input schema, and ``UnexpectedToolError`` subclasses it, so + ``except ToolError`` around ``MCPServer.call_tool()`` catches every tool + failure, crash or not. + + Pass *content* to return rich error content (images, embedded resources, + multiple text blocks, etc.) alongside ``is_error=True``. When *content* is + ``None`` (the default) the string message is wrapped in a single + ``TextContent`` block, preserving backward compatibility. + + Example:: + + raise ToolError( + "screenshot of the failure", + content=[ + TextContent(type="text", text="rendering failed"), + ImageContent(type="image", data=b64_png, mime_type="image/png"), + ], + ) """ + content: list[ContentBlock] | None + """Optional rich content blocks for the error result.""" + + def __init__(self, message: str = "", *, content: list[ContentBlock] | None = None) -> None: + super().__init__(message) + 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 93bef1655a..cd29c46e74 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -438,6 +438,10 @@ 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) + # Use custom content from the ToolError when provided; otherwise + # fall back to wrapping the message string as a single TextContent. + if isinstance(exc, ToolError) and exc.content is not None: + return CallToolResult(content=list(exc.content), 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 4a8bed792e..de0b7f8c85 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -204,7 +204,8 @@ 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 + content = exc.content if isinstance(exc, ToolError) else None + raise ToolError(f"Error executing tool {self.name}: {exc}", content=content) 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 3f90ce1368..4128ba4efc 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -2486,6 +2486,92 @@ def spend() -> str: ) +async def test_tool_error_with_custom_content_returns_rich_is_error_result(): + """ToolError with custom content returns that content with is_error=True + instead of wrapping the message string.""" + mcp = MCPServer() + + @mcp.tool() + def render(url: str) -> str: + raise ToolError( + "rendering failed", + content=[ + TextContent(type="text", text="could not render the page"), + ImageContent(type="image", data="iVBORw0KGgo=", mime_type="image/png"), + ], + ) + + async with Client(mcp) as client: + result = await client.call_tool("render", {"url": "https://example.com"}) + + assert result.is_error is True + assert len(result.content) == 2 + assert result.content[0] == TextContent(type="text", text="could not render the page") + assert isinstance(result.content[1], ImageContent) + assert result.content[1].mime_type == "image/png" + + +async def test_tool_error_without_content_falls_back_to_text(caplog: pytest.LogCaptureFixture): + """A plain ToolError (no content kwarg) still wraps str(exc) in TextContent, + preserving backward compatibility.""" + mcp = MCPServer() + + @mcp.tool() + def fail() -> str: + raise ToolError("something broke") + + async with Client(mcp) as client: + result = await client.call_tool("fail", {}) + + assert result.is_error is True + assert result.content == [TextContent(type="text", text="Error executing tool fail: something broke")] + + +async def test_tool_error_with_content_propagates_through_programmatic_call(): + """When call_tool is called programmatically, the re-raised ToolError + preserves the custom content attribute.""" + mcp = MCPServer() + + error_content = [ + TextContent(type="text", text="structured error info"), + ImageContent(type="image", data="iVBORw0KGgo=", mime_type="image/png"), + ] + + @mcp.tool() + def analyze() -> str: + raise ToolError("analysis failed", content=error_content) + + with pytest.raises(ToolError) as exc: + await mcp.call_tool("analyze", {}) + + assert exc.value.content is not None + assert len(exc.value.content) == 2 + assert exc.value.content[0] == TextContent(type="text", text="structured error info") + + +async def test_tool_error_with_content_is_logged_at_info(caplog: pytest.LogCaptureFixture): + """A ToolError with custom content is still logged at INFO, same as a plain ToolError.""" + mcp = MCPServer() + + @mcp.tool() + def render() -> str: + raise ToolError( + "rendering failed", + content=[TextContent(type="text", text="detailed failure info")], + ) + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("render", {}) + + assert result.is_error is True + assert result.content == [TextContent(type="text", text="detailed failure info")] + assert _server_records(caplog) == snapshot( + [("INFO", "Tool 'render' failed: 'Error executing tool render: rendering failed'", False)] + ) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback( caplog: pytest.LogCaptureFixture, ):