diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 8095c451d..3fdc16a17 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -1,5 +1,7 @@ """Custom exceptions for MCPServer.""" +from typing import Any + class MCPServerError(Exception): """Base error for MCPServer.""" @@ -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): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 2064bd60c..22c837ec4 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -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 @@ -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( diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 29894d7d1..9b9a477e4 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -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 diff --git a/tests/server/mcpserver/tools/test_base.py b/tests/server/mcpserver/tools/test_base.py index 5e20f61ad..b343d536c 100644 --- a/tests/server/mcpserver/tools/test_base.py +++ b/tests/server/mcpserver/tools/test_base.py @@ -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 @@ -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"