"""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
def _env_server(env: dict[str, str]) -> McpServerConfig:
return McpServerConfig(
transport="stdio",
command=sys.executable,
args=["-m", "tests._mcp_test_server"],
env=env,
)
@pytest.mark.anyio
async def test_credentials_are_resolved_at_connect(monkeypatch):
"""`${VAR}` in a config reaches the server as the environment's value.
Resolution happens at transport open (`navi/mcp/client.py`), which is what
lets the config object itself keep the placeholder — and therefore what
stops `save_mcp_servers` from writing a credential into a tracked file.
"""
monkeypatch.setenv("NAVI_TEST_INTEGRATION_TOKEN", "resolved-value")
cfg = _env_server({"TOKEN": "${NAVI_TEST_INTEGRATION_TOKEN}"})
client = McpClient("test", cfg)
await client.connect()
try:
result = await client.call_tool("echo_env", {"name": "TOKEN"})
assert "resolved-value" in result
assert client.config.env["TOKEN"] == "${NAVI_TEST_INTEGRATION_TOKEN}"
finally:
await client.disconnect()
@pytest.mark.anyio
async def test_a_missing_credential_fails_the_connection(monkeypatch):
"""The connection is refused, not made without the header: a server may
answer an unauthenticated request as an anonymous user instead of 401."""
monkeypatch.delenv("NAVI_TEST_DEFINITELY_UNSET", raising=False)
client = McpClient("test", _env_server({"TOKEN": "${NAVI_TEST_DEFINITELY_UNSET}"}))
with pytest.raises(ValueError) as excinfo:
await client.connect()
assert "NAVI_TEST_DEFINITELY_UNSET" in str(excinfo.value)
assert not client.connected