diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index bc79c44a36..0e2e2eca90 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -421,6 +421,7 @@ async def _handle_call_tool( except MCPError: raise except Exception as e: + logger.exception(f"Error calling tool {params.name}") return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True) async def _handle_list_resources( diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index a6418ac9c5..3ff7e2af85 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -119,6 +119,32 @@ def flux() -> str: ) +@requirement("mcpserver:tool:handler-throws") +async def test_call_tool_exception_is_logged_server_side( + connect: Connect, caplog: pytest.LogCaptureFixture +) -> None: + """A tool exception is logged at ERROR level on the server so operators can diagnose failures. + + The is_error result reaches the client, but without server-side logging the root cause is + invisible in server logs. This test asserts the log record is emitted alongside the result. + """ + mcp = MCPServer("errors") + + @mcp.tool() + def boom() -> str: + raise RuntimeError("something went wrong") + + with caplog.at_level(logging.ERROR, logger="mcp.server.mcpserver.server"): + async with connect(mcp) as client: + result = await client.call_tool("boom", {}) + + assert result.is_error is True + assert any( + rec.levelno == logging.ERROR and "boom" in rec.message + for rec in caplog.records + ) + + @requirement("mcpserver:tool:unknown-name") async def test_call_tool_unknown_name_returns_error_result(connect: Connect, unstamped: Unstamp) -> None: """Calling a tool name that was never registered is reported as an is_error result.