Skip to content
Open
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
19 changes: 18 additions & 1 deletion src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Custom exceptions for MCPServer."""

from typing import Any


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


class ToolError(MCPServerError):
"""Error in tool operations."""
"""Error in tool operations.

Raise this from a tool function to return a ``CallToolResult`` with
``is_error=True``. By default the error message becomes the result's text
content. Pass ``content`` to attach arbitrary result content - for example an
image or embedded resource - to the error result instead of the message text.
"""

def __init__(self, message: str = "", *, content: list[Any] | None = None) -> None:
# `content` carries `mcp.types.ContentBlock` items. It is typed as
# `list[Any]` rather than `list[ContentBlock]` because this module is
# imported during `mcp` package initialization, before `mcp.types` is
# importable - referencing that type here would create a circular import.
# `_handle_call_tool` places the items straight into `CallToolResult.content`.
super().__init__(message)
self.content = content


class InvalidSignature(Exception):
Expand Down
9 changes: 7 additions & 2 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from mcp.server.lowlevel.server import LifespanResultT, Server
from mcp.server.lowlevel.server import lifespan as default_lifespan
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError, ToolError
from mcp.server.mcpserver.prompts import Prompt, PromptManager
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
from mcp.server.mcpserver.tools import Tool, ToolManager
Expand Down Expand Up @@ -312,7 +312,12 @@ async def _handle_call_tool(
return await self.call_tool(params.name, params.arguments or {}, context)
except MCPError:
raise
except Exception as e:
except ToolError as e:
# Tool execution failures surface as `ToolError` (the tool layer wraps
# any non-`MCPError` exception). Use the content the tool attached, if
# any, otherwise fall back to the error message as text.
if e.content is not None:
return CallToolResult(content=e.content, is_error=True)
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)

async def _handle_list_resources(
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,10 @@ async def run(
# it as a top-level JSON-RPC error rather than wrapping it as a
# `CallToolResult(isError=True)` execution failure.
raise
except ToolError as e:
# The tool deliberately signalled an error. Preserve any content it
# attached (e.g. an image) so it survives to the `CallToolResult`,
# while keeping the execution-failure prefix on the message.
raise ToolError(f"Error executing tool {self.name}: {e}", content=e.content) from e
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e
28 changes: 28 additions & 0 deletions tests/server/mcpserver/tools/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from mcp import Client, types
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.tools.base import Tool
from mcp.shared.exceptions import MCPError

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

assert isinstance(result, types.CallToolResult)
assert result.is_error is True


@pytest.mark.anyio
async def test_tool_error_with_content_attaches_that_content_to_the_is_error_result():
"""SDK-defined: a tool can raise ``ToolError(content=...)`` to return a
``CallToolResult(isError=True)`` carrying arbitrary content - e.g. an image -
rather than only the error message as text. The content survives the wrap the
tool layer applies to exceptions."""
mcp = MCPServer(name="srv")

@mcp.tool()
async def render() -> str:
raise ToolError(
"rendering failed",
content=[types.ImageContent(type="image", data="aGVsbG8=", mime_type="image/png")],
)

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

assert isinstance(result, types.CallToolResult)
assert result.is_error is True
assert len(result.content) == 1
block = result.content[0]
assert isinstance(block, types.ImageContent)
assert block.data == "aGVsbG8="
assert block.mime_type == "image/png"
Loading