From 57fa37dcfe779bf6cd0f120c6e0cbf817660a610 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:50:39 +0800 Subject: [PATCH] fix(sdk): close MCP connections from Nanobot facade The SDK opened MCP connections through AgentLoop.process_direct but never called close_mcp, leaving stdio MCP generators to be finalized during asyncio shutdown from a different task, producing a RuntimeError about exiting a cancel scope in a different task. Add aclose() that delegates to AgentLoop.close_mcp (which already drains background tasks and closes MCP stacks), plus __aenter__ and __aexit__ so the SDK works as an async context manager. Fixes #4211 --- nanobot/nanobot.py | 9 +++++++++ tests/test_nanobot_facade.py | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 95185ba47..ace49ac0c 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -101,4 +101,13 @@ class Nanobot: messages=capture.messages, ) + async def aclose(self) -> None: + """Release resources held by this instance (MCP connections, etc.).""" + await self._loop.close_mcp() + + async def __aenter__(self) -> Nanobot: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index 24f4681db..25474d9e2 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -326,3 +326,40 @@ async def test_sdk_capture_prefers_run_level_snapshot(): assert hook.tools_used == ["read_file"] assert hook.messages == final_messages + + +@pytest.mark.asyncio +async def test_aclose_delegates_to_loop_close_mcp(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + bot._loop.close_mcp = AsyncMock() + + await bot.aclose() + + bot._loop.close_mcp.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_context_manager_calls_aclose_on_exit(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + bot._loop.close_mcp = AsyncMock() + + async with bot as b: + assert b is bot + + bot._loop.close_mcp.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_context_manager_does_not_swallow_exceptions(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + bot._loop.close_mcp = AsyncMock() + + with pytest.raises(ValueError): + async with bot as b: + assert b is bot + raise ValueError("boom") + + bot._loop.close_mcp.assert_awaited_once()