Skip to content

Commit bb1df17

Browse files
committed
Add custom content support to ToolError for isError responses
ToolError can now accept an optional `content` parameter to return arbitrary content blocks (TextContent, ImageContent, etc.) with isError=True, enabling rich error responses beyond plain text. Changes: - Add `content` parameter to ToolError exception class - Handle ToolError with custom content in lowlevel server - Re-raise ToolError in FastMCP's Tool.run to preserve content - Add comprehensive tests for both Server and FastMCP APIs Github-Issue: #348
1 parent c7cbfbb commit bb1df17

File tree

4 files changed

+248
-1
lines changed

4 files changed

+248
-1
lines changed

src/mcp/server/fastmcp/exceptions.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
"""Custom exceptions for FastMCP."""
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 FastMCPError(Exception):
512
"""Base error for FastMCP."""
@@ -14,7 +21,34 @@ class ResourceError(FastMCPError):
1421

1522

1623
class ToolError(FastMCPError):
17-
"""Error in tool operations."""
24+
"""Error in tool operations.
25+
26+
Can be raised with custom content to return non-text error responses.
27+
28+
Args:
29+
message: Error message (used if content is not provided)
30+
content: Optional list of content blocks to return as the error response.
31+
If provided, these will be used instead of the message for the error content.
32+
33+
Examples:
34+
# Simple text error (existing behavior)
35+
raise ToolError("Something went wrong")
36+
37+
# Error with custom content (e.g., image)
38+
raise ToolError(
39+
"Image processing failed",
40+
content=[ImageContent(type="image", data="...", mimeType="image/png")]
41+
)
42+
"""
43+
44+
def __init__(
45+
self,
46+
message: str = "",
47+
*,
48+
content: list[ContentBlock] | None = None,
49+
) -> None:
50+
super().__init__(message)
51+
self.content = content
1852

1953

2054
class InvalidSignature(Exception):

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ async def run(
113113
# Re-raise UrlElicitationRequiredError so it can be properly handled
114114
# as an MCP error response with code -32042
115115
raise
116+
except ToolError:
117+
# Re-raise ToolError as-is to preserve custom content
118+
raise
116119
except Exception as e:
117120
raise ToolError(f"Error executing tool {self.name}: {e}") from e
118121

src/mcp/server/lowlevel/server.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ async def main():
8484

8585
import mcp.types as types
8686
from mcp.server.experimental.request_context import Experimental
87+
from mcp.server.fastmcp.exceptions import ToolError
8788
from mcp.server.lowlevel.experimental import ExperimentalHandlers
8889
from mcp.server.lowlevel.func_inspection import create_call_wrapper
8990
from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -573,6 +574,16 @@ async def handler(req: types.CallToolRequest):
573574
# Re-raise UrlElicitationRequiredError so it can be properly handled
574575
# by _handle_request, which converts it to an error response with code -32042
575576
raise
577+
except ToolError as e:
578+
# ToolError can have custom content for the error response
579+
if e.content is not None:
580+
return types.ServerResult(
581+
types.CallToolResult(
582+
content=e.content,
583+
isError=True,
584+
)
585+
)
586+
return self._make_error_result(str(e))
576587
except Exception as e:
577588
return self._make_error_result(str(e))
578589

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Test for issue #348: ToolError with custom content for isError responses.
2+
3+
Issue #348 reported that there was no way to set isError=True for arbitrary content
4+
like Images. This was because ToolError only accepted a string message which was
5+
converted to TextContent.
6+
7+
The fix adds an optional `content` parameter to ToolError that allows passing
8+
arbitrary content blocks (TextContent, ImageContent, etc.) which will be returned
9+
with isError=True.
10+
"""
11+
12+
from typing import Any
13+
14+
import pytest
15+
16+
from mcp.server import Server
17+
from mcp.server.fastmcp import FastMCP
18+
from mcp.server.fastmcp.exceptions import ToolError
19+
from mcp.shared.memory import (
20+
create_connected_server_and_client_session as client_session,
21+
)
22+
from mcp.types import ImageContent, TextContent, Tool
23+
24+
pytestmark = pytest.mark.anyio
25+
26+
27+
def create_tool(name: str, description: str) -> Tool:
28+
"""Create a test tool with the given name and description."""
29+
return Tool(name=name, description=description, inputSchema={"type": "object"})
30+
31+
32+
async def test_tool_error_with_text_message():
33+
"""Test that ToolError with just a message returns text content with isError=True."""
34+
server = Server("test")
35+
36+
@server.list_tools()
37+
async def list_tools():
38+
return [create_tool("fail", "Always fails")]
39+
40+
@server.call_tool()
41+
async def call_tool(name: str, arguments: dict[str, Any]):
42+
raise ToolError("Something went wrong")
43+
44+
async with client_session(server) as client:
45+
result = await client.call_tool("fail", {})
46+
47+
assert result.isError is True
48+
assert len(result.content) == 1
49+
assert isinstance(result.content[0], TextContent)
50+
assert "Something went wrong" in result.content[0].text
51+
52+
53+
async def test_tool_error_with_custom_text_content():
54+
"""Test that ToolError with custom TextContent returns that content with isError=True."""
55+
server = Server("test")
56+
57+
@server.list_tools()
58+
async def list_tools():
59+
return [create_tool("fail", "Fails with custom content")]
60+
61+
@server.call_tool()
62+
async def call_tool(name: str, arguments: dict[str, Any]):
63+
raise ToolError(
64+
"Error occurred",
65+
content=[
66+
TextContent(type="text", text="Custom error message 1"),
67+
TextContent(type="text", text="Custom error message 2"),
68+
],
69+
)
70+
71+
async with client_session(server) as client:
72+
result = await client.call_tool("fail", {})
73+
74+
assert result.isError is True
75+
assert len(result.content) == 2
76+
assert isinstance(result.content[0], TextContent)
77+
assert result.content[0].text == "Custom error message 1"
78+
assert isinstance(result.content[1], TextContent)
79+
assert result.content[1].text == "Custom error message 2"
80+
81+
82+
async def test_tool_error_with_image_content():
83+
"""Test that ToolError with ImageContent returns image with isError=True."""
84+
server = Server("test")
85+
# Base64 encoded 1x1 red PNG
86+
red_pixel = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
87+
88+
@server.list_tools()
89+
async def list_tools():
90+
return [create_tool("fail", "Fails with image")]
91+
92+
@server.call_tool()
93+
async def call_tool(name: str, arguments: dict[str, Any]):
94+
raise ToolError(
95+
"Image processing failed",
96+
content=[
97+
ImageContent(type="image", data=red_pixel, mimeType="image/png"),
98+
TextContent(type="text", text="Error details"),
99+
],
100+
)
101+
102+
async with client_session(server) as client:
103+
result = await client.call_tool("fail", {})
104+
105+
assert result.isError is True
106+
assert len(result.content) == 2
107+
assert isinstance(result.content[0], ImageContent)
108+
assert result.content[0].data == red_pixel
109+
assert result.content[0].mimeType == "image/png"
110+
assert isinstance(result.content[1], TextContent)
111+
assert result.content[1].text == "Error details"
112+
113+
114+
async def test_tool_success_returns_is_error_false():
115+
"""Test that successful tool call returns isError=False."""
116+
server = Server("test")
117+
118+
@server.list_tools()
119+
async def list_tools():
120+
return [create_tool("succeed", "Always succeeds")]
121+
122+
@server.call_tool()
123+
async def call_tool(name: str, arguments: dict[str, Any]):
124+
return [TextContent(type="text", text="Success")]
125+
126+
async with client_session(server) as client:
127+
result = await client.call_tool("succeed", {})
128+
129+
assert result.isError is False
130+
assert len(result.content) == 1
131+
assert isinstance(result.content[0], TextContent)
132+
assert result.content[0].text == "Success"
133+
134+
135+
async def test_tool_error_with_empty_content_list():
136+
"""Test that ToolError with empty content list returns empty content with isError=True."""
137+
server = Server("test")
138+
139+
@server.list_tools()
140+
async def list_tools():
141+
return [create_tool("fail", "Fails with empty content")]
142+
143+
@server.call_tool()
144+
async def call_tool(name: str, arguments: dict[str, Any]):
145+
raise ToolError("Error message", content=[])
146+
147+
async with client_session(server) as client:
148+
result = await client.call_tool("fail", {})
149+
150+
assert result.isError is True
151+
assert len(result.content) == 0
152+
153+
154+
# FastMCP tests - verify the feature works with the high-level API
155+
156+
157+
async def test_fastmcp_tool_error_with_custom_content():
158+
"""Test that ToolError with custom content works in FastMCP."""
159+
mcp = FastMCP("test")
160+
161+
@mcp.tool()
162+
def fail_with_image() -> str:
163+
raise ToolError(
164+
"Processing failed",
165+
content=[
166+
ImageContent(
167+
type="image",
168+
data="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
169+
mimeType="image/png",
170+
),
171+
TextContent(type="text", text="Details about the failure"),
172+
],
173+
)
174+
175+
async with client_session(mcp) as client:
176+
result = await client.call_tool("fail_with_image", {})
177+
178+
assert result.isError is True
179+
assert len(result.content) == 2
180+
assert isinstance(result.content[0], ImageContent)
181+
assert isinstance(result.content[1], TextContent)
182+
assert result.content[1].text == "Details about the failure"
183+
184+
185+
async def test_fastmcp_tool_error_with_text_message():
186+
"""Test that ToolError with just a message still works in FastMCP."""
187+
mcp = FastMCP("test")
188+
189+
@mcp.tool()
190+
def fail_simple() -> str:
191+
raise ToolError("Simple error message")
192+
193+
async with client_session(mcp) as client:
194+
result = await client.call_tool("fail_simple", {})
195+
196+
assert result.isError is True
197+
assert len(result.content) == 1
198+
assert isinstance(result.content[0], TextContent)
199+
assert "Simple error message" in result.content[0].text

0 commit comments

Comments
 (0)