mcp: dedicated runner task per client (fix cross-task cancel-scope on shutdown)
MCP client SDK transports (stdio/sse/streamable_http) + ClientSession are
anyio task groups whose cancel scopes require __aenter__/__aexit__ in the
SAME asyncio task. McpClient entered the transport in one task (lifespan
connect / health-check reconnect / request retry) and exited it in another
(lifespan teardown) -> RuntimeError: Attempted to exit cancel scope in a
different task than it was entered in.

Refactor McpClient to own a single long-running runner task that holds the
AsyncExitStack and performs ALL transport enter/exit + list_tools/call_tool.
The public async API (connect/disconnect/list_tools/call_tool/mark_disconnected)
just enqueues a _Cmd and awaits a Future, so callers from any task no longer
cross cancel-scope boundaries. connected/instructions mirror from the runner
onto the instance to stay sync-readable. disconnect() enqueues a stop command
and awaits shield(runner) so teardown isn't interrupted by lifespan cancel.

Also call mcp_manager.stop_health_check() BEFORE disconnect_all() in
AppContainer.shutdown() so the health-check task cannot enqueue onto a
client whose runner is being torn down. mark_disconnected() is now async
(queued) and its manager caller updated.

Regression test: connect in one task, list_tools in a second, disconnect in
a third — the exact scenario that raised the RuntimeError before.
1 parent 3406226 commit 271800dbb14074aa87bd62548b9214a0b1769635
@Eugene Sukhodolskiy Eugene Sukhodolskiy authored 7 hours ago
Showing 4 changed files
View
navi/core/container.py
View
navi/mcp/client.py
View
navi/mcp/manager.py
View
tests/integration/test_mcp_integration.py