From f879ec6fd14100f7171dc3e755a04e9fa328137a Mon Sep 17 00:00:00 2001 From: Stooby with two y's <97161453+Stoobyy@users.noreply.github.com> Date: Thu, 28 May 2026 21:42:03 +0400 Subject: [PATCH] Fix #348: Allow ToolError to carry custom content and is_error Adds `content` and `is_error` parameters to ToolError so tool functions can return arbitrary content blocks with isError=True (e.g., images, structured data alongside error status). - exceptions.py: ToolError gains content and is_error parameters - tools/base.py: Re-raise ToolError instead of wrapping it - server.py: Catch ToolError, construct CallToolResult with proper content - tests: 2 new tests covering ToolError with custom content and default Closes #348 Co-authored-by: CommandCodeBot --- src/mcp/server/mcpserver/exceptions.py | 23 +++++++++++++++- src/mcp/server/mcpserver/server.py | 5 +++- src/mcp/server/mcpserver/tools/base.py | 3 +++ tests/server/mcpserver/test_server.py | 37 ++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index dd1b75e829..abddf5574a 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -1,4 +1,5 @@ """Custom exceptions for MCPServer.""" +from __future__ import annotations class MCPServerError(Exception): @@ -14,7 +15,27 @@ class ResourceError(MCPServerError): class ToolError(MCPServerError): - """Error in tool operations.""" + """Error in tool operations. + + Can be raised from tool functions to return a tool result with + is_error=True and arbitrary content (e.g., images, structured data). + + Args: + message: Error message (used as text content if no content provided). + content: Optional list of ContentBlock items to return as tool result content. + is_error: Whether to set is_error on the CallToolResult (default True). + """ + + def __init__( + self, + message: str, + *, + content: list | None = None, + is_error: bool = True, + ) -> None: + super().__init__(message) + self.content = content + self.is_error = is_error class InvalidSignature(Exception): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index b3471163b7..6762961cff 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -31,7 +31,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 +from mcp.server.mcpserver.exceptions import ResourceError, 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 @@ -310,6 +310,9 @@ async def _handle_call_tool( context = Context(request_context=ctx, mcp_server=self) try: result = await self.call_tool(params.name, params.arguments or {}, context) + except ToolError as e: + content = e.content if e.content is not None else [TextContent(type="text", text=str(e))] + return CallToolResult(content=content, is_error=e.is_error) except MCPError: raise except Exception as e: diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 754313eb8a..d71d1dba2e 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -111,6 +111,9 @@ async def run( result = self.fn_metadata.convert_result(result) return result + except ToolError: + # Re-raise ToolError so custom content and is_error propagate + raise except UrlElicitationRequiredError: # Re-raise UrlElicitationRequiredError so it can be properly handled # as an MCP error response with code -32042 diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 3457ec944a..fd0211bbe5 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -292,6 +292,43 @@ async def test_tool_error_details(self): assert "Test error" in content.text assert result.is_error is True + async def test_tool_error_with_content(self): + """Test that ToolError with custom content returns is_error=True.""" + + def tool_fn() -> None: + raise ToolError( + "Something went wrong", + content=[ + TextContent(type="text", text="Custom error"), + ImageContent(type="image", data="base64...", mimeType="image/png"), + ], + ) + + mcp = MCPServer() + mcp.add_tool(tool_fn) + async with Client(mcp) as client: + result = await client.call_tool("tool_fn", {}) + assert result.is_error is True + assert len(result.content) == 2 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Custom error" + assert isinstance(result.content[1], ImageContent) + + async def test_tool_error_default_content(self): + """Test that ToolError without custom content falls back to the error message.""" + + def tool_fn() -> None: + raise ToolError("Default error message") + + mcp = MCPServer() + mcp.add_tool(tool_fn) + async with Client(mcp) as client: + result = await client.call_tool("tool_fn", {}) + assert result.is_error is True + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert "Default error message" in result.content[0].text + async def test_tool_return_value_conversion(self): mcp = MCPServer() mcp.add_tool(tool_fn)