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
23 changes: 22 additions & 1 deletion src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Custom exceptions for MCPServer."""
from __future__ import annotations


class MCPServerError(Exception):
Expand All @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading