Newer
Older
navi-1 / tests / integration / test_mcp_integration.py
"""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