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
49 changes: 40 additions & 9 deletions src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
"""Custom exceptions for MCPServer."""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from mcp_types import ContentBlock


class MCPServerError(Exception):
"""Base error for MCPServer."""
Expand Down Expand Up @@ -44,19 +51,43 @@ class ToolError(MCPServerError):
"""A tool failure you anticipated.

Raise this from a tool (or a resolver) for a failure you saw coming: the
call returns `is_error=True` with your message in `content` for the model to
read, and the server logs it at INFO without a traceback. A `ResourceError`
that escapes the tool (say from `ctx.read_resource()`) counts the same. Any
other exception bar `MCPError` (a protocol error) is treated as a crash: the
model sees only `Error executing tool <name>`, and the server logs the
traceback at ERROR. Inside a pydantic validator, raise `ValueError` as pydantic
expects; it arrives as an argument-validation failure, which is anticipated too.
call returns ``is_error=True`` with your message in ``content`` for the model
to read, and the server logs it at INFO without a traceback. A
``ResourceError`` that escapes the tool (say from ``ctx.read_resource()``)
counts the same. Any other exception bar ``MCPError`` (a protocol error) is
treated as a crash: the model sees only ``Error executing tool <name>``, and
the server logs the traceback at ERROR. Inside a pydantic validator, raise
``ValueError`` as pydantic expects; it arrives as an argument-validation
failure, which is anticipated too.

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.
the input schema, and ``UnexpectedToolError`` subclasses it, so
``except ToolError`` around ``MCPServer.call_tool()`` catches every tool
failure, crash or not.

Pass *content* to return rich error content (images, embedded resources,
multiple text blocks, etc.) alongside ``is_error=True``. When *content* is
``None`` (the default) the string message is wrapped in a single
``TextContent`` block, preserving backward compatibility.

Example::

raise ToolError(
"screenshot of the failure",
content=[
TextContent(type="text", text="rendering failed"),
ImageContent(type="image", data=b64_png, mime_type="image/png"),
],
)
"""

content: list[ContentBlock] | None
"""Optional rich content blocks for the error result."""

def __init__(self, message: str = "", *, content: list[ContentBlock] | None = None) -> None:
super().__init__(message)
self.content = content


class UnexpectedToolError(ToolError):
"""A tool call failed with something other than `ToolError`, `ResourceError`, or `MCPError`.
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,10 @@ 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)
# Use custom content from the ToolError when provided; otherwise
# fall back to wrapping the message string as a single TextContent.
if isinstance(exc, ToolError) and exc.content is not None:
return CallToolResult(content=list(exc.content), is_error=True)
return CallToolResult(content=[TextContent(type="text", text=str(exc))], is_error=True)

async def _handle_list_resources(
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ 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
content = exc.content if isinstance(exc, ToolError) else None
raise ToolError(f"Error executing tool {self.name}: {exc}", content=content) 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
86 changes: 86 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2486,6 +2486,92 @@ def spend() -> str:
)


async def test_tool_error_with_custom_content_returns_rich_is_error_result():
"""ToolError with custom content returns that content with is_error=True
instead of wrapping the message string."""
mcp = MCPServer()

@mcp.tool()
def render(url: str) -> str:
raise ToolError(
"rendering failed",
content=[
TextContent(type="text", text="could not render the page"),
ImageContent(type="image", data="iVBORw0KGgo=", mime_type="image/png"),
],
)

async with Client(mcp) as client:
result = await client.call_tool("render", {"url": "https://example.com"})

assert result.is_error is True
assert len(result.content) == 2
assert result.content[0] == TextContent(type="text", text="could not render the page")
assert isinstance(result.content[1], ImageContent)
assert result.content[1].mime_type == "image/png"


async def test_tool_error_without_content_falls_back_to_text(caplog: pytest.LogCaptureFixture):
"""A plain ToolError (no content kwarg) still wraps str(exc) in TextContent,
preserving backward compatibility."""
mcp = MCPServer()

@mcp.tool()
def fail() -> str:
raise ToolError("something broke")

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

assert result.is_error is True
assert result.content == [TextContent(type="text", text="Error executing tool fail: something broke")]


async def test_tool_error_with_content_propagates_through_programmatic_call():
"""When call_tool is called programmatically, the re-raised ToolError
preserves the custom content attribute."""
mcp = MCPServer()

error_content = [
TextContent(type="text", text="structured error info"),
ImageContent(type="image", data="iVBORw0KGgo=", mime_type="image/png"),
]

@mcp.tool()
def analyze() -> str:
raise ToolError("analysis failed", content=error_content)

with pytest.raises(ToolError) as exc:
await mcp.call_tool("analyze", {})

assert exc.value.content is not None
assert len(exc.value.content) == 2
assert exc.value.content[0] == TextContent(type="text", text="structured error info")


async def test_tool_error_with_content_is_logged_at_info(caplog: pytest.LogCaptureFixture):
"""A ToolError with custom content is still logged at INFO, same as a plain ToolError."""
mcp = MCPServer()

@mcp.tool()
def render() -> str:
raise ToolError(
"rendering failed",
content=[TextContent(type="text", text="detailed failure info")],
)

caplog.set_level(logging.INFO)
async with Client(mcp) as client:
result = await client.call_tool("render", {})

assert result.is_error is True
assert result.content == [TextContent(type="text", text="detailed failure info")]
assert _server_records(caplog) == snapshot(
[("INFO", "Tool 'render' failed: 'Error executing tool render: rendering failed'", False)]
)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]


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