Skip to content

Commit 9b6b9af

Browse files
author
Raed Lazreg
committed
feat(mcpserver): let ToolError carry content for is_error results
Tool authors can now raise ToolError(content=[...]) to return a CallToolResult with is_error=True that carries arbitrary content (e.g. an image or embedded resource) instead of only the error message as text. A plain ToolError behaves exactly as before. content is typed as list[Any] rather than list[ContentBlock] because exceptions.py is imported during mcp package initialization, before mcp.types is importable - referencing that type would create a circular import. Closes #348
1 parent 03681ed commit 9b6b9af

4 files changed

Lines changed: 58 additions & 3 deletions

File tree

src/mcp/server/mcpserver/exceptions.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Custom exceptions for MCPServer."""
22

3+
from typing import Any
4+
35

46
class MCPServerError(Exception):
57
"""Base error for MCPServer."""
@@ -23,7 +25,22 @@ class ResourceNotFoundError(ResourceError):
2325

2426

2527
class ToolError(MCPServerError):
26-
"""Error in tool operations."""
28+
"""Error in tool operations.
29+
30+
Raise this from a tool function to return a ``CallToolResult`` with
31+
``is_error=True``. By default the error message becomes the result's text
32+
content. Pass ``content`` to attach arbitrary result content - for example an
33+
image or embedded resource - to the error result instead of the message text.
34+
"""
35+
36+
def __init__(self, message: str = "", *, content: list[Any] | None = None) -> None:
37+
# `content` carries `mcp.types.ContentBlock` items. It is typed as
38+
# `list[Any]` rather than `list[ContentBlock]` because this module is
39+
# imported during `mcp` package initialization, before `mcp.types` is
40+
# importable - referencing that type here would create a circular import.
41+
# `_handle_call_tool` places the items straight into `CallToolResult.content`.
42+
super().__init__(message)
43+
self.content = content
2744

2845

2946
class InvalidSignature(Exception):

src/mcp/server/mcpserver/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from mcp.server.lowlevel.server import LifespanResultT, Server
3131
from mcp.server.lowlevel.server import lifespan as default_lifespan
3232
from mcp.server.mcpserver.context import Context
33-
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError
33+
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError, ToolError
3434
from mcp.server.mcpserver.prompts import Prompt, PromptManager
3535
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
3636
from mcp.server.mcpserver.tools import Tool, ToolManager
@@ -312,7 +312,12 @@ async def _handle_call_tool(
312312
return await self.call_tool(params.name, params.arguments or {}, context)
313313
except MCPError:
314314
raise
315-
except Exception as e:
315+
except ToolError as e:
316+
# Tool execution failures surface as `ToolError` (the tool layer wraps
317+
# any non-`MCPError` exception). Use the content the tool attached, if
318+
# any, otherwise fall back to the error message as text.
319+
if e.content is not None:
320+
return CallToolResult(content=e.content, is_error=True)
316321
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
317322

318323
async def _handle_list_resources(

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,10 @@ async def run(
118118
# it as a top-level JSON-RPC error rather than wrapping it as a
119119
# `CallToolResult(isError=True)` execution failure.
120120
raise
121+
except ToolError as e:
122+
# The tool deliberately signalled an error. Preserve any content it
123+
# attached (e.g. an image) so it survives to the `CallToolResult`,
124+
# while keeping the execution-failure prefix on the message.
125+
raise ToolError(f"Error executing tool {self.name}: {e}", content=e.content) from e
121126
except Exception as e:
122127
raise ToolError(f"Error executing tool {self.name}: {e}") from e

tests/server/mcpserver/tools/test_base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from mcp import Client, types
44
from mcp.server.mcpserver import Context, MCPServer
5+
from mcp.server.mcpserver.exceptions import ToolError
56
from mcp.server.mcpserver.tools.base import Tool
67
from mcp.shared.exceptions import MCPError
78

@@ -54,3 +55,30 @@ async def boom() -> str:
5455

5556
assert isinstance(result, types.CallToolResult)
5657
assert result.is_error is True
58+
59+
60+
@pytest.mark.anyio
61+
async def test_tool_error_with_content_attaches_that_content_to_the_is_error_result():
62+
"""SDK-defined: a tool can raise ``ToolError(content=...)`` to return a
63+
``CallToolResult(isError=True)`` carrying arbitrary content - e.g. an image -
64+
rather than only the error message as text. The content survives the wrap the
65+
tool layer applies to exceptions."""
66+
mcp = MCPServer(name="srv")
67+
68+
@mcp.tool()
69+
async def render() -> str:
70+
raise ToolError(
71+
"rendering failed",
72+
content=[types.ImageContent(type="image", data="aGVsbG8=", mime_type="image/png")],
73+
)
74+
75+
async with Client(mcp) as client:
76+
result = await client.call_tool("render", {})
77+
78+
assert isinstance(result, types.CallToolResult)
79+
assert result.is_error is True
80+
assert len(result.content) == 1
81+
block = result.content[0]
82+
assert isinstance(block, types.ImageContent)
83+
assert block.data == "aGVsbG8="
84+
assert block.mime_type == "image/png"

0 commit comments

Comments
 (0)