|
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. |
|---|
|
|
| navi/core/container.py |
|---|
| navi/mcp/client.py |
|---|
| navi/mcp/manager.py |
|---|
| tests/integration/test_mcp_integration.py |
|---|