Skip to content

Commit f879ec6

Browse files
Fix #348: Allow ToolError to carry custom content and is_error
Adds `content` and `is_error` parameters to ToolError so tool functions can return arbitrary content blocks with isError=True (e.g., images, structured data alongside error status). - exceptions.py: ToolError gains content and is_error parameters - tools/base.py: Re-raise ToolError instead of wrapping it - server.py: Catch ToolError, construct CallToolResult with proper content - tests: 2 new tests covering ToolError with custom content and default Closes #348 Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
1 parent 3eb5799 commit f879ec6

4 files changed

Lines changed: 66 additions & 2 deletions

File tree

src/mcp/server/mcpserver/exceptions.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Custom exceptions for MCPServer."""
2+
from __future__ import annotations
23

34

45
class MCPServerError(Exception):
@@ -14,7 +15,27 @@ class ResourceError(MCPServerError):
1415

1516

1617
class ToolError(MCPServerError):
17-
"""Error in tool operations."""
18+
"""Error in tool operations.
19+
20+
Can be raised from tool functions to return a tool result with
21+
is_error=True and arbitrary content (e.g., images, structured data).
22+
23+
Args:
24+
message: Error message (used as text content if no content provided).
25+
content: Optional list of ContentBlock items to return as tool result content.
26+
is_error: Whether to set is_error on the CallToolResult (default True).
27+
"""
28+
29+
def __init__(
30+
self,
31+
message: str,
32+
*,
33+
content: list | None = None,
34+
is_error: bool = True,
35+
) -> None:
36+
super().__init__(message)
37+
self.content = content
38+
self.is_error = is_error
1839

1940

2041
class InvalidSignature(Exception):

src/mcp/server/mcpserver/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from mcp.server.lowlevel.server import LifespanResultT, Server
3232
from mcp.server.lowlevel.server import lifespan as default_lifespan
3333
from mcp.server.mcpserver.context import Context
34-
from mcp.server.mcpserver.exceptions import ResourceError
34+
from mcp.server.mcpserver.exceptions import ResourceError, ToolError
3535
from mcp.server.mcpserver.prompts import Prompt, PromptManager
3636
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
3737
from mcp.server.mcpserver.tools import Tool, ToolManager
@@ -310,6 +310,9 @@ async def _handle_call_tool(
310310
context = Context(request_context=ctx, mcp_server=self)
311311
try:
312312
result = await self.call_tool(params.name, params.arguments or {}, context)
313+
except ToolError as e:
314+
content = e.content if e.content is not None else [TextContent(type="text", text=str(e))]
315+
return CallToolResult(content=content, is_error=e.is_error)
313316
except MCPError:
314317
raise
315318
except Exception as e:

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ async def run(
111111
result = self.fn_metadata.convert_result(result)
112112

113113
return result
114+
except ToolError:
115+
# Re-raise ToolError so custom content and is_error propagate
116+
raise
114117
except UrlElicitationRequiredError:
115118
# Re-raise UrlElicitationRequiredError so it can be properly handled
116119
# as an MCP error response with code -32042

tests/server/mcpserver/test_server.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,43 @@ async def test_tool_error_details(self):
292292
assert "Test error" in content.text
293293
assert result.is_error is True
294294

295+
async def test_tool_error_with_content(self):
296+
"""Test that ToolError with custom content returns is_error=True."""
297+
298+
def tool_fn() -> None:
299+
raise ToolError(
300+
"Something went wrong",
301+
content=[
302+
TextContent(type="text", text="Custom error"),
303+
ImageContent(type="image", data="base64...", mimeType="image/png"),
304+
],
305+
)
306+
307+
mcp = MCPServer()
308+
mcp.add_tool(tool_fn)
309+
async with Client(mcp) as client:
310+
result = await client.call_tool("tool_fn", {})
311+
assert result.is_error is True
312+
assert len(result.content) == 2
313+
assert isinstance(result.content[0], TextContent)
314+
assert result.content[0].text == "Custom error"
315+
assert isinstance(result.content[1], ImageContent)
316+
317+
async def test_tool_error_default_content(self):
318+
"""Test that ToolError without custom content falls back to the error message."""
319+
320+
def tool_fn() -> None:
321+
raise ToolError("Default error message")
322+
323+
mcp = MCPServer()
324+
mcp.add_tool(tool_fn)
325+
async with Client(mcp) as client:
326+
result = await client.call_tool("tool_fn", {})
327+
assert result.is_error is True
328+
assert len(result.content) == 1
329+
assert isinstance(result.content[0], TextContent)
330+
assert "Default error message" in result.content[0].text
331+
295332
async def test_tool_return_value_conversion(self):
296333
mcp = MCPServer()
297334
mcp.add_tool(tool_fn)

0 commit comments

Comments
 (0)