Skip to content

Commit e7bd514

Browse files
committed
Merge PR #1728: harden MCP tool cancellation handling
2 parents 51d113d + 5eb67fa commit e7bd514

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

nanobot/agent/tools/mcp.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def parameters(self) -> dict[str, Any]:
3636

3737
async def execute(self, **kwargs: Any) -> str:
3838
from mcp import types
39+
3940
try:
4041
result = await asyncio.wait_for(
4142
self._session.call_tool(self._original_name, arguments=kwargs),
@@ -44,6 +45,23 @@ async def execute(self, **kwargs: Any) -> str:
4445
except asyncio.TimeoutError:
4546
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
4647
return f"(MCP tool call timed out after {self._tool_timeout}s)"
48+
except asyncio.CancelledError:
49+
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
50+
# Re-raise only if our task was externally cancelled (e.g. /stop).
51+
task = asyncio.current_task()
52+
if task is not None and task.cancelling() > 0:
53+
raise
54+
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
55+
return "(MCP tool call was cancelled)"
56+
except Exception as exc:
57+
logger.exception(
58+
"MCP tool '{}' failed: {}: {}",
59+
self._name,
60+
type(exc).__name__,
61+
exc,
62+
)
63+
return f"(MCP tool call failed: {type(exc).__name__})"
64+
4765
parts = []
4866
for block in result.content:
4967
if isinstance(block, types.TextContent):

tests/test_mcp_tool.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import sys
5+
from types import ModuleType, SimpleNamespace
6+
7+
import pytest
8+
9+
from nanobot.agent.tools.mcp import MCPToolWrapper
10+
11+
12+
class _FakeTextContent:
13+
def __init__(self, text: str) -> None:
14+
self.text = text
15+
16+
17+
@pytest.fixture(autouse=True)
18+
def _fake_mcp_module(monkeypatch: pytest.MonkeyPatch) -> None:
19+
mod = ModuleType("mcp")
20+
mod.types = SimpleNamespace(TextContent=_FakeTextContent)
21+
monkeypatch.setitem(sys.modules, "mcp", mod)
22+
23+
24+
def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
25+
tool_def = SimpleNamespace(
26+
name="demo",
27+
description="demo tool",
28+
inputSchema={"type": "object", "properties": {}},
29+
)
30+
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_execute_returns_text_blocks() -> None:
35+
async def call_tool(_name: str, arguments: dict) -> object:
36+
assert arguments == {"value": 1}
37+
return SimpleNamespace(content=[_FakeTextContent("hello"), 42])
38+
39+
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
40+
41+
result = await wrapper.execute(value=1)
42+
43+
assert result == "hello\n42"
44+
45+
46+
@pytest.mark.asyncio
47+
async def test_execute_returns_timeout_message() -> None:
48+
async def call_tool(_name: str, arguments: dict) -> object:
49+
await asyncio.sleep(1)
50+
return SimpleNamespace(content=[])
51+
52+
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=0.01)
53+
54+
result = await wrapper.execute()
55+
56+
assert result == "(MCP tool call timed out after 0.01s)"
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_execute_handles_server_cancelled_error() -> None:
61+
async def call_tool(_name: str, arguments: dict) -> object:
62+
raise asyncio.CancelledError()
63+
64+
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
65+
66+
result = await wrapper.execute()
67+
68+
assert result == "(MCP tool call was cancelled)"
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_execute_re_raises_external_cancellation() -> None:
73+
started = asyncio.Event()
74+
75+
async def call_tool(_name: str, arguments: dict) -> object:
76+
started.set()
77+
await asyncio.sleep(60)
78+
return SimpleNamespace(content=[])
79+
80+
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10)
81+
task = asyncio.create_task(wrapper.execute())
82+
await started.wait()
83+
84+
task.cancel()
85+
86+
with pytest.raises(asyncio.CancelledError):
87+
await task
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_execute_handles_generic_exception() -> None:
92+
async def call_tool(_name: str, arguments: dict) -> object:
93+
raise RuntimeError("boom")
94+
95+
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
96+
97+
result = await wrapper.execute()
98+
99+
assert result == "(MCP tool call failed: RuntimeError)"

0 commit comments

Comments
 (0)