Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,18 @@ class ToolError(MCPServerError):
The SDK raises it too, for an unknown tool name and for arguments that fail
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
around `MCPServer.call_tool()` catches every tool failure, crash or not.

Args:
message: The error message for the model to read.
content: Optional tool result content to include in the error response.
When provided, these content blocks are returned with `is_error=True`
instead of a single text block derived from `message`.
"""

def __init__(self, message: str = "", *args: object, content: object = None) -> None:
super().__init__(message, *args)
self.content = content


class UnexpectedToolError(ToolError):
"""A tool call failed with something other than `ToolError`, `ResourceError`, or `MCPError`.
Expand Down
11 changes: 11 additions & 0 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,17 @@ async def _handle_call_tool(
logger.info("Tool %r failed: %r", params.name, str(exc))
else:
logger.exception("Tool %r raised an unexpected exception", params.name)
if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError) and getattr(exc, "content", None) is not None:
from mcp.server.mcpserver.utilities.func_metadata import _convert_to_content

raw = exc.content
if isinstance(raw, (list, tuple)):
blocks: list[Any] = []
for item in raw:
blocks.extend(_convert_to_content(item))
else:
blocks = _convert_to_content(raw)
return CallToolResult(content=blocks, is_error=True)
return CallToolResult(content=[TextContent(type="text", text=str(exc))], is_error=True)

async def _handle_list_resources(
Expand Down
5 changes: 4 additions & 1 deletion src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ async def run(
raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc
except (ToolError, ResourceError) as exc:
# Raised deliberately by the tool, a resolver, or a resource it read.
raise ToolError(f"Error executing tool {self.name}: {exc}") from exc
wrapped = ToolError(f"Error executing tool {self.name}: {exc}")
if isinstance(exc, ToolError) and getattr(exc, "content", None) is not None:
wrapped.content = exc.content
raise wrapped from exc
except Exception as exc:
# A crash: the exception's own text stays on the server.
raise UnexpectedToolError(f"Error executing tool {self.name}") from exc
38 changes: 38 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2562,6 +2562,44 @@ def spend() -> str:
)



async def test_tool_error_with_content_returns_content_in_error_result():
"""ToolError with content returns that content in the is_error result."""
mcp = MCPServer()

@mcp.tool()
def analyze() -> str:
raise ToolError(
"analysis failed",
content=[TextContent(type="text", text="partial output"), ImageContent(type="image", data="YWJj", mime_type="image/png")],
)

async with Client(mcp) as client:
result = await client.call_tool("analyze", {})

assert result.is_error is True
assert len(result.content) == 2
assert result.content[0] == TextContent(type="text", text="partial output")
assert result.content[1] == ImageContent(type="image", data="YWJj", mime_type="image/png")


async def test_tool_error_with_image_helper_content_returns_image_in_error_result():
"""ToolError with Image helper content converts to ImageContent in is_error result."""
mcp = MCPServer()

@mcp.tool()
def screenshot() -> str:
raise ToolError("capture failed", content=[Image(data=b"abc", format="png")])

async with Client(mcp) as client:
result = await client.call_tool("screenshot", {})

assert result.is_error is True
assert len(result.content) == 1
assert result.content[0].type == "image"
assert result.content[0].mime_type == "image/png"


async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback(
caplog: pytest.LogCaptureFixture,
):
Expand Down
Loading