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
10 changes: 8 additions & 2 deletions 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 @@ -303,8 +303,14 @@ async def _handle_call_tool(
result = await self.call_tool(params.name, params.arguments or {}, context)
except MCPError:
raise
except Exception as e:
except ToolError as e:
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
except Exception:
logger.exception(f"Error calling tool {params.name}")
return CallToolResult(
content=[TextContent(type="text", text=f"An unexpected error occurred calling tool {params.name}")],
is_error=True,
)
if isinstance(result, CallToolResult):
return result
if isinstance(result, tuple) and len(result) == 2:
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,10 @@ async def run(
# Re-raise UrlElicitationRequiredError so it can be properly handled
# as an MCP error response with code -32042
raise
except ToolError:
raise
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e
raise ToolError(f"An unexpected error occurred executing tool {self.name}") from e


def _is_async_callable(obj: Any) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion tests/client/test_list_roots_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ async def test_list_roots(context: Context[None], message: str):
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
assert result.content[0].text == "An unexpected error occurred executing tool test_list_roots"
2 changes: 1 addition & 1 deletion tests/client/test_sampling_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async def test_sampling_tool(message: str, ctx: Context) -> bool:
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
assert result.content[0].text == "An unexpected error occurred executing tool test_sampling"


@pytest.mark.anyio
Expand Down
40 changes: 36 additions & 4 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ def error_tool_fn() -> None:
raise ValueError("Test error")


def tool_error_fn() -> None:
raise ToolError("Intentional tool error")


def image_tool_fn(path: str) -> Image:
return Image(path)

Expand Down Expand Up @@ -258,7 +262,8 @@ async def test_tool_exception_handling(self):
assert len(result.content) == 1
content = result.content[0]
assert isinstance(content, TextContent)
assert "Test error" in content.text
assert "unexpected error" in content.text.lower()
assert "Test error" not in content.text
assert result.is_error is True

async def test_tool_error_handling(self):
Expand All @@ -269,20 +274,47 @@ async def test_tool_error_handling(self):
assert len(result.content) == 1
content = result.content[0]
assert isinstance(content, TextContent)
assert "Test error" in content.text
assert "unexpected error" in content.text.lower()
assert "Test error" not in content.text
assert result.is_error is True

async def test_tool_error_details(self):
"""Test that exception details are properly formatted in the response"""
"""Test that exception details are NOT exposed to the client"""
mcp = MCPServer()
mcp.add_tool(error_tool_fn)
async with Client(mcp) as client:
result = await client.call_tool("error_tool_fn", {})
content = result.content[0]
assert isinstance(content, TextContent)
assert isinstance(content.text, str)
assert "Test error" in content.text
assert "error_tool_fn" in content.text
assert "Test error" not in content.text
assert result.is_error is True

async def test_tool_error_passthrough(self):
"""Test that ToolError raised in tool is passed through with its message."""
mcp = MCPServer()
mcp.add_tool(tool_error_fn)
async with Client(mcp) as client:
result = await client.call_tool("tool_error_fn", {})
assert result.is_error is True
content = result.content[0]
assert isinstance(content, TextContent)
assert "Intentional tool error" in content.text

async def test_handle_call_tool_defensive_exception_handler(self):
"""Test that _handle_call_tool returns generic error when call_tool raises unexpected Exception."""
mcp = MCPServer()
mcp.add_tool(tool_fn)

async with Client(mcp) as client:
with patch.object(mcp, "call_tool", new_callable=AsyncMock, side_effect=RuntimeError("internal db leak")):
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
assert result.is_error is True
content = result.content[0]
assert isinstance(content, TextContent)
assert "unexpected error" in content.text.lower()
assert "internal db leak" not in content.text

async def test_tool_return_value_conversion(self):
mcp = MCPServer()
Expand Down
2 changes: 1 addition & 1 deletion tests/server/mcpserver/test_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str:
manager = ToolManager()
manager.add_tool(tool_with_context)

with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
with pytest.raises(ToolError, match="unexpected error occurred executing tool tool_with_context"):
await manager.call_tool("tool_with_context", {"x": 42}, context=Context())


Expand Down
4 changes: 3 additions & 1 deletion tests/server/mcpserver/test_url_elicitation_error_throw.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,6 @@ async def failing_tool(ctx: Context[ServerSession, None]) -> str:
assert result.is_error is True
assert len(result.content) == 1
assert isinstance(result.content[0], types.TextContent)
assert "Something went wrong" in result.content[0].text
assert "An unexpected error occurred executing tool failing_tool" in result.content[0].text
# Verify the original exception details are NOT leaked to the client
assert "Something went wrong" not in result.content[0].text