Skip to content

Commit dc4a3be

Browse files
committed
Add isError support for non-text tool results (Fixes #348)
- Add CallToolResult.create_error() classmethod for convenient error results with non-text content - Add model_validator to auto-convert SDK Image/Audio helpers to wire content types - Allow Image/Audio helpers directly in CallToolResult.content - Add tests for image, audio, and structured content error results
1 parent 80cd21e commit dc4a3be

5 files changed

Lines changed: 160 additions & 2 deletions

File tree

src/mcp-types/mcp_types/_types.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,60 @@ class CallToolResult(Result):
14831483
result_type: ResultType = "complete"
14841484
"""See `ResultType`. Always serialized; older peers ignore it."""
14851485

1486+
@model_validator(mode="before")
1487+
@classmethod
1488+
def _convert_helpers(cls, data: Any) -> Any:
1489+
"""Auto-convert SDK Image/Audio helpers to wire content types.
1490+
1491+
This allows users to pass SDK helper objects (which have `to_image_content()`
1492+
or `to_audio_content()` methods) directly in `content` without manual conversion.
1493+
"""
1494+
if isinstance(data, dict) and "content" in data:
1495+
content = data["content"]
1496+
if isinstance(content, list):
1497+
converted = []
1498+
for item in content:
1499+
if hasattr(item, "to_image_content"):
1500+
converted.append(item.to_image_content())
1501+
elif hasattr(item, "to_audio_content"):
1502+
converted.append(item.to_audio_content())
1503+
else:
1504+
converted.append(item)
1505+
data["content"] = converted
1506+
return data
1507+
1508+
@classmethod
1509+
def create_error(
1510+
cls,
1511+
content: list[ContentBlock],
1512+
*,
1513+
structured_content: Any = None,
1514+
) -> Self:
1515+
"""Create a CallToolResult with is_error=True.
1516+
1517+
This is a convenience method for returning tool errors with non-text content
1518+
(images, audio, structured data) without raising an exception.
1519+
1520+
Args:
1521+
content: List of content blocks (text, image, audio, etc.)
1522+
structured_content: Optional structured data payload
1523+
1524+
Returns:
1525+
CallToolResult with is_error=True
1526+
1527+
Example:
1528+
```python
1529+
from mcp.server.mcpserver.utilities.types import Image
1530+
from mcp.types import CallToolResult
1531+
1532+
@mcp.tool()
1533+
async def my_tool() -> CallToolResult:
1534+
img = Image(data=b'...', format='png')
1535+
return CallToolResult.create_error(content=[img])
1536+
```
1537+
"""
1538+
return cls(content=content, structured_content=structured_content, is_error=True)
1539+
14861540

14871541
class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]):
14881542
"""An optional notification from the server to the client, informing it that the list

src/mcp-types/mcp_types/_v2025_11_25/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from __future__ import annotations
88

9-
from typing import Annotated, Any, Literal
9+
from typing import Annotated, Any, Literal, Self
1010

1111
from mcp_types._wire_base import WireModel
1212
from pydantic import ConfigDict, Field, RootModel
@@ -3172,6 +3172,20 @@ class CallToolResult(WireModel):
31723172
An optional JSON object that represents the structured result of the tool call.
31733173
"""
31743174

3175+
@classmethod
3176+
def create_error(
3177+
cls,
3178+
content: list[ContentBlock],
3179+
*,
3180+
structured_content: Any = None,
3181+
) -> Self:
3182+
"""Create a CallToolResult with isError=True.
3183+
3184+
This is a convenience method for returning tool errors with non-text content
3185+
(images, audio, structured data) without raising an exception.
3186+
"""
3187+
return cls(content=content, structured_content=structured_content, is_error=True)
3188+
31753189

31763190
class ClientNotification(
31773191
RootModel[

src/mcp-types/mcp_types/_v2026_07_28/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from __future__ import annotations
88

9-
from typing import Annotated, Any, Literal, Union
9+
from typing import Annotated, Any, Literal, Self, Union
1010

1111
from mcp_types._wire_base import WireModel
1212
from pydantic import ConfigDict, Field, RootModel
@@ -2730,6 +2730,20 @@ class CallToolResult(WireModel):
27302730
that conforms to the tool's outputSchema if one is defined.
27312731
"""
27322732

2733+
@classmethod
2734+
def create_error(
2735+
cls,
2736+
content: list[ContentBlock],
2737+
*,
2738+
structured_content: Any = None,
2739+
) -> Self:
2740+
"""Create a CallToolResult with isError=True.
2741+
2742+
This is a convenience method for returning tool errors with non-text content
2743+
(images, audio, structured data) without raising an exception.
2744+
"""
2745+
return cls(content=content, structured_content=structured_content, is_error=True)
2746+
27332747

27342748
class CancelledNotification(WireModel):
27352749
"""

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,22 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
174174
if isinstance(result, CallToolResult):
175175
if output_model is not None:
176176
self._output_adapter(output_model).validate_python(result.structured_content)
177+
# Convert any Image/Audio helpers in content to their wire types
178+
converted_content = []
179+
for block in result.content:
180+
if isinstance(block, Image):
181+
converted_content.append(block.to_image_content())
182+
elif isinstance(block, Audio):
183+
converted_content.append(block.to_audio_content())
184+
else:
185+
converted_content.append(block)
186+
if converted_content != list(result.content):
187+
return CallToolResult(
188+
content=converted_content,
189+
structured_content=result.structured_content,
190+
is_error=result.is_error,
191+
result_type=result.result_type,
192+
)
177193
return result
178194

179195
unstructured_content = _convert_to_content(result)

tests/server/mcpserver/tools/test_base.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,63 @@ async def boom() -> str:
5555

5656
assert isinstance(result, types.CallToolResult)
5757
assert result.is_error is True
58+
59+
60+
@pytest.mark.anyio
61+
async def test_call_tool_result_create_error_with_image():
62+
"""A tool can return CallToolResult.create_error() with Image helper for non-text error content."""
63+
mcp = MCPServer(name="srv")
64+
65+
@mcp.tool()
66+
async def image_error() -> types.CallToolResult:
67+
from mcp.server.mcpserver.utilities.types import Image
68+
img = Image(data=b"fake-png", format="png")
69+
return types.CallToolResult.create_error(content=[img])
70+
71+
async with Client(mcp) as client:
72+
result = await client.call_tool("image_error", {})
73+
74+
assert isinstance(result, types.CallToolResult)
75+
assert result.is_error is True
76+
assert len(result.content) == 1
77+
assert isinstance(result.content[0], types.ImageContent)
78+
79+
80+
@pytest.mark.anyio
81+
async def test_call_tool_result_create_error_with_audio():
82+
"""A tool can return CallToolResult.create_error() with Audio helper for non-text error content."""
83+
mcp = MCPServer(name="srv")
84+
85+
@mcp.tool()
86+
async def audio_error() -> types.CallToolResult:
87+
from mcp.server.mcpserver.utilities.types import Audio
88+
aud = Audio(data=b"fake-wav", format="wav")
89+
return types.CallToolResult.create_error(content=[aud])
90+
91+
async with Client(mcp) as client:
92+
result = await client.call_tool("audio_error", {})
93+
94+
assert isinstance(result, types.CallToolResult)
95+
assert result.is_error is True
96+
assert len(result.content) == 1
97+
assert isinstance(result.content[0], types.AudioContent)
98+
99+
100+
@pytest.mark.anyio
101+
async def test_call_tool_result_create_error_with_structured_content():
102+
"""A tool can return CallToolResult.create_error() with structured content."""
103+
mcp = MCPServer(name="srv")
104+
105+
@mcp.tool()
106+
async def structured_error() -> types.CallToolResult:
107+
return types.CallToolResult.create_error(
108+
content=[types.TextContent(type="text", text="Something went wrong")],
109+
structured_content={"error_code": "INVALID_INPUT", "details": {"field": "email"}},
110+
)
111+
112+
async with Client(mcp) as client:
113+
result = await client.call_tool("structured_error", {})
114+
115+
assert isinstance(result, types.CallToolResult)
116+
assert result.is_error is True
117+
assert result.structured_content == {"error_code": "INVALID_INPUT", "details": {"field": "email"}}

0 commit comments

Comments
 (0)