From ed3b9c16f959d5820298673fe732d899dec9a593 Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sun, 8 Mar 2026 08:05:18 -0600 Subject: [PATCH] fix: handle CancelledError in MCP tool calls to prevent process crash MCP SDK's anyio cancel scopes can leak CancelledError on timeout or failure paths. Since CancelledError is a BaseException (not Exception), it escapes both MCPToolWrapper.execute() and ToolRegistry.execute(), crashing the agent loop. Now catches CancelledError and returns a graceful error to the LLM, while still re-raising genuine task cancellations from /stop. Also catches general Exception for other MCP failures (connection drops, invalid responses, etc.). Related: #1055 --- nanobot/agent/tools/mcp.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 2cbffd0..cf0a842 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -34,7 +34,7 @@ class MCPToolWrapper(Tool): def parameters(self) -> dict[str, Any]: return self._parameters - async def execute(self, **kwargs: Any) -> str: +async def execute(self, **kwargs: Any) -> str: from mcp import types try: result = await asyncio.wait_for( @@ -44,13 +44,24 @@ class MCPToolWrapper(Tool): except asyncio.TimeoutError: logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) return f"(MCP tool call timed out after {self._tool_timeout}s)" + except asyncio.CancelledError: + # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure. + # Re-raise only if our task was externally cancelled (e.g. /stop). + task = asyncio.current_task() + if task is not None and task.cancelling() > 0: + raise + logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) + return f"(MCP tool call was cancelled)" + except Exception as exc: + logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc) + return f"(MCP tool call failed: {type(exc).__name__})" parts = [] for block in result.content: if isinstance(block, types.TextContent): parts.append(block.text) else: parts.append(str(block)) - return "\n".join(parts) or "(no output)" + return "\n".join(parts) or "(no output) async def connect_mcp_servers(