Skip to content

Commit 865b2af

Browse files
Sushant JoshiSushant Joshi
authored andcommitted
feat: add optional content parameter to ToolError for rich error responses
Allow ToolError to carry arbitrary content blocks (images, embedded resources, multiple text blocks) that are returned as-is in the CallToolResult with is_error=True. When content is not provided, the existing behavior of wrapping str(exc) in a single TextContent block is preserved. Fixes #348
1 parent 6705402 commit 865b2af

4 files changed

Lines changed: 132 additions & 10 deletions

File tree

src/mcp/server/mcpserver/exceptions.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
"""Custom exceptions for MCPServer."""
22

3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from mcp_types import ContentBlock
9+
310

411
class MCPServerError(Exception):
512
"""Base error for MCPServer."""
@@ -44,19 +51,43 @@ class ToolError(MCPServerError):
4451
"""A tool failure you anticipated.
4552
4653
Raise this from a tool (or a resolver) for a failure you saw coming: the
47-
call returns `is_error=True` with your message in `content` for the model to
48-
read, and the server logs it at INFO without a traceback. A `ResourceError`
49-
that escapes the tool (say from `ctx.read_resource()`) counts the same. Any
50-
other exception bar `MCPError` (a protocol error) is treated as a crash: the
51-
model sees only `Error executing tool <name>`, and the server logs the
52-
traceback at ERROR. Inside a pydantic validator, raise `ValueError` as pydantic
53-
expects; it arrives as an argument-validation failure, which is anticipated too.
54+
call returns ``is_error=True`` with your message in ``content`` for the model
55+
to read, and the server logs it at INFO without a traceback. A
56+
``ResourceError`` that escapes the tool (say from ``ctx.read_resource()``)
57+
counts the same. Any other exception bar ``MCPError`` (a protocol error) is
58+
treated as a crash: the model sees only ``Error executing tool <name>``, and
59+
the server logs the traceback at ERROR. Inside a pydantic validator, raise
60+
``ValueError`` as pydantic expects; it arrives as an argument-validation
61+
failure, which is anticipated too.
5462
5563
The SDK raises it too, for an unknown tool name and for arguments that fail
56-
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
57-
around `MCPServer.call_tool()` catches every tool failure, crash or not.
64+
the input schema, and ``UnexpectedToolError`` subclasses it, so
65+
``except ToolError`` around ``MCPServer.call_tool()`` catches every tool
66+
failure, crash or not.
67+
68+
Pass *content* to return rich error content (images, embedded resources,
69+
multiple text blocks, etc.) alongside ``is_error=True``. When *content* is
70+
``None`` (the default) the string message is wrapped in a single
71+
``TextContent`` block, preserving backward compatibility.
72+
73+
Example::
74+
75+
raise ToolError(
76+
"screenshot of the failure",
77+
content=[
78+
TextContent(type="text", text="rendering failed"),
79+
ImageContent(type="image", data=b64_png, mime_type="image/png"),
80+
],
81+
)
5882
"""
5983

84+
content: list[ContentBlock] | None
85+
"""Optional rich content blocks for the error result."""
86+
87+
def __init__(self, message: str = "", *, content: list[ContentBlock] | None = None) -> None:
88+
super().__init__(message)
89+
self.content = content
90+
6091

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

src/mcp/server/mcpserver/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,10 @@ async def _handle_call_tool(
438438
logger.info("Tool %r failed: %r", params.name, str(exc))
439439
else:
440440
logger.exception("Tool %r raised an unexpected exception", params.name)
441+
# Use custom content from the ToolError when provided; otherwise
442+
# fall back to wrapping the message string as a single TextContent.
443+
if isinstance(exc, ToolError) and exc.content is not None:
444+
return CallToolResult(content=list(exc.content), is_error=True)
441445
return CallToolResult(content=[TextContent(type="text", text=str(exc))], is_error=True)
442446

443447
async def _handle_list_resources(

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ async def run(
204204
raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc
205205
except (ToolError, ResourceError) as exc:
206206
# Raised deliberately by the tool, a resolver, or a resource it read.
207-
raise ToolError(f"Error executing tool {self.name}: {exc}") from exc
207+
content = exc.content if isinstance(exc, ToolError) else None
208+
raise ToolError(f"Error executing tool {self.name}: {exc}", content=content) from exc
208209
except Exception as exc:
209210
# A crash: the exception's own text stays on the server.
210211
raise UnexpectedToolError(f"Error executing tool {self.name}") from exc

tests/server/mcpserver/test_server.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2486,6 +2486,92 @@ def spend() -> str:
24862486
)
24872487

24882488

2489+
async def test_tool_error_with_custom_content_returns_rich_is_error_result():
2490+
"""ToolError with custom content returns that content with is_error=True
2491+
instead of wrapping the message string."""
2492+
mcp = MCPServer()
2493+
2494+
@mcp.tool()
2495+
def render(url: str) -> str:
2496+
raise ToolError(
2497+
"rendering failed",
2498+
content=[
2499+
TextContent(type="text", text="could not render the page"),
2500+
ImageContent(type="image", data="iVBORw0KGgo=", mime_type="image/png"),
2501+
],
2502+
)
2503+
2504+
async with Client(mcp) as client:
2505+
result = await client.call_tool("render", {"url": "https://example.com"})
2506+
2507+
assert result.is_error is True
2508+
assert len(result.content) == 2
2509+
assert result.content[0] == TextContent(type="text", text="could not render the page")
2510+
assert isinstance(result.content[1], ImageContent)
2511+
assert result.content[1].mime_type == "image/png"
2512+
2513+
2514+
async def test_tool_error_without_content_falls_back_to_text(caplog: pytest.LogCaptureFixture):
2515+
"""A plain ToolError (no content kwarg) still wraps str(exc) in TextContent,
2516+
preserving backward compatibility."""
2517+
mcp = MCPServer()
2518+
2519+
@mcp.tool()
2520+
def fail() -> str:
2521+
raise ToolError("something broke")
2522+
2523+
async with Client(mcp) as client:
2524+
result = await client.call_tool("fail", {})
2525+
2526+
assert result.is_error is True
2527+
assert result.content == [TextContent(type="text", text="Error executing tool fail: something broke")]
2528+
2529+
2530+
async def test_tool_error_with_content_propagates_through_programmatic_call():
2531+
"""When call_tool is called programmatically, the re-raised ToolError
2532+
preserves the custom content attribute."""
2533+
mcp = MCPServer()
2534+
2535+
error_content = [
2536+
TextContent(type="text", text="structured error info"),
2537+
ImageContent(type="image", data="iVBORw0KGgo=", mime_type="image/png"),
2538+
]
2539+
2540+
@mcp.tool()
2541+
def analyze() -> str:
2542+
raise ToolError("analysis failed", content=error_content)
2543+
2544+
with pytest.raises(ToolError) as exc:
2545+
await mcp.call_tool("analyze", {})
2546+
2547+
assert exc.value.content is not None
2548+
assert len(exc.value.content) == 2
2549+
assert exc.value.content[0] == TextContent(type="text", text="structured error info")
2550+
2551+
2552+
async def test_tool_error_with_content_is_logged_at_info(caplog: pytest.LogCaptureFixture):
2553+
"""A ToolError with custom content is still logged at INFO, same as a plain ToolError."""
2554+
mcp = MCPServer()
2555+
2556+
@mcp.tool()
2557+
def render() -> str:
2558+
raise ToolError(
2559+
"rendering failed",
2560+
content=[TextContent(type="text", text="detailed failure info")],
2561+
)
2562+
2563+
caplog.set_level(logging.INFO)
2564+
async with Client(mcp) as client:
2565+
result = await client.call_tool("render", {})
2566+
2567+
assert result.is_error is True
2568+
assert result.content == [TextContent(type="text", text="detailed failure info")]
2569+
assert _server_records(caplog) == snapshot(
2570+
[("INFO", "Tool 'render' failed: 'Error executing tool render: rendering failed'", False)]
2571+
)
2572+
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
2573+
2574+
24892575
async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback(
24902576
caplog: pytest.LogCaptureFixture,
24912577
):

0 commit comments

Comments
 (0)