"""Integration test for navi/mcp/ against a real stdio MCP server."""
import asyncio
import sys
import pytest
from navi.mcp.client import McpClient
from navi.mcp.config import McpServerConfig
@pytest.mark.anyio
async def test_stdio_client_connects_lists_and_calls_tools():
cfg = McpServerConfig(
transport="stdio",
command=sys.executable,
args=["-m", "tests._mcp_test_server"],
)
client = McpClient("test", cfg)
await client.connect()
assert client.connected
tools = await client.list_tools()
names = {t.name for t in tools}
assert "hello" in names
assert "add" in names
result = await client.call_tool("hello", {"name": "Navi"})
assert "Hello, Navi!" in result
result = await client.call_tool("add", {"a": 2, "b": 3})
assert "5" in result
await client.disconnect()
assert not client.connected
@pytest.mark.anyio
async def test_disconnect_from_different_task_than_connect():
"""Regression: connect in one task, disconnect in another.
The MCP client SDK uses anyio task groups whose cancel scopes require
``__aexit__`` in the same task as ``__aenter__``. Before the dedicated
runner-task refactor this raised ``RuntimeError: Attempted to exit cancel
scope in a different task than it was entered in``. The runner owns the
AsyncExitStack, so enter and exit always happen in its one task.
"""
cfg = McpServerConfig(
transport="stdio",
command=sys.executable,
args=["-m", "tests._mcp_test_server"],
)
client = McpClient("test", cfg)
async def _connect() -> None:
await client.connect()
async def _use() -> None:
# A second task also exercises the session so the transport's anyio
# task group is genuinely live when teardown begins.
await client.list_tools()
async def _disconnect() -> None:
await client.disconnect()
await asyncio.ensure_future(_connect())
assert client.connected
await asyncio.ensure_future(_use())
# Teardown from a *different* task than the one that connected — must not
# raise the cross-task cancel-scope RuntimeError.
await asyncio.ensure_future(_disconnect())
assert not client.connected