Newer
Older
navi-1 / tests / unit / test_mcp.py
"""Unit tests for navi/mcp/ infrastructure."""

from unittest.mock import AsyncMock

import pytest

from navi.mcp.client import McpClient
from navi.mcp.config import McpServerConfig, load_mcp_servers
from navi.mcp.manager import McpManager
from navi.mcp.tools import McpTool


class TestMcpServerConfig:
    def test_stdio_config(self):
        cfg = McpServerConfig(
            transport="stdio",
            command="python",
            args=["-m", "app.mcp_server"],
            env={"FOO": "bar"},
        )
        assert cfg.is_stdio
        assert not cfg.is_sse
        assert cfg.command == "python"

    def test_sse_config(self):
        cfg = McpServerConfig(
            transport="sse",
            url="http://localhost:3001/sse",
            headers={"Authorization": "Bearer token"},
        )
        assert cfg.is_sse
        assert not cfg.is_stdio
        assert cfg.url == "http://localhost:3001/sse"

    def test_default_transport_is_stdio(self):
        cfg = McpServerConfig(command="npx")
        assert cfg.transport == "stdio"


class TestLoadMcpServers:
    def test_missing_file_returns_empty(self, tmp_path):
        result = load_mcp_servers(tmp_path / "nonexistent.json")
        assert result == {}

    def test_loads_valid_json(self, tmp_path):
        path = tmp_path / "mcp_servers.json"
        path.write_text(
            '{"book": {"transport": "stdio", "command": "python", "args": ["server.py"]}}'
        )
        result = load_mcp_servers(path)
        assert "book" in result
        assert result["book"].command == "python"


class TestMcpManager:
    async def test_load_all_with_empty_config(self):
        manager = McpManager()
        await manager.load_all({})
        assert manager.clients == {}

    async def test_disconnect_all_when_empty(self):
        manager = McpManager()
        await manager.disconnect_all()  # should not raise

    async def test_get_all_tools_skips_broken_server(self):
        manager = McpManager()
        client = AsyncMock(spec=McpClient)
        client.connected = True
        client.list_tools.side_effect = RuntimeError("boom")
        manager._clients = {"bad": client}

        tools = await manager.get_all_tools()
        assert tools == []

    def test_get_instructions_returns_selected_config_overlay_when_disconnected(self, tmp_path):
        path = tmp_path / "mcp_servers.json"
        path.write_text(
            '{"gnexus-book": {"transport": "sse", "url": "http://example/sse", "instructions": "Use book."}}'
        )
        manager = McpManager(config_path=path)

        instructions = manager.get_instructions({"gnexus-book"})

        assert instructions == {"gnexus-book": "Use book."}

    def test_get_instructions_filters_to_selected_servers(self, tmp_path):
        path = tmp_path / "mcp_servers.json"
        path.write_text(
            '{"gnexus-book": {"instructions": "Use book."}, "other": {"instructions": "Use other."}}'
        )
        manager = McpManager(config_path=path)

        instructions = manager.get_instructions({"gnexus-book"})

        assert instructions == {"gnexus-book": "Use book."}


class TestMcpTool:
    def test_name_prefix(self):
        mock_manager = AsyncMock(spec=McpManager)
        tool = McpTool(
            server_name="gnexus-book",
            tool_name="search_docs",
            description="Search docs",
            parameters={"type": "object", "properties": {}},
            manager=mock_manager,
        )
        assert tool.name == "mcp__gnexus-book__search_docs"

    async def test_execute_success(self):
        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = ("found 3 results", False)
        tool = McpTool(
            server_name="book",
            tool_name="search",
            description="",
            parameters={},
            manager=mock_manager,
        )
        result = await tool.execute({"query": "foo"})
        assert result.success
        assert result.output == "found 3 results"
        mock_manager.call_tool.assert_awaited_once_with("book", "search", {"query": "foo"})

    async def test_execute_mcp_error(self):
        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = ("validation failed", True)
        tool = McpTool(
            server_name="book",
            tool_name="apply",
            description="",
            parameters={},
            manager=mock_manager,
        )
        result = await tool.execute({})
        assert not result.success
        assert result.output == "validation failed"
        assert "MCP tool reported an error" in result.error

    async def test_execute_failure(self):
        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.side_effect = RuntimeError("server down")
        tool = McpTool(
            server_name="book",
            tool_name="search",
            description="",
            parameters={},
            manager=mock_manager,
        )
        result = await tool.execute({})
        assert not result.success
        assert "server down" in result.error

    async def test_execute_injects_session_id(self):
        from navi.tools._internal.base import current_session_id

        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = ("ok", False)
        tool = McpTool(
            server_name="book",
            tool_name="search",
            description="",
            parameters={},
            manager=mock_manager,
        )
        token = current_session_id.set("real-session-id")
        try:
            result = await tool.execute({"query": "foo"})
            assert result.success
            mock_manager.call_tool.assert_awaited_once_with(
                "book", "search", {"query": "foo", "session_id": "real-session-id"}
            )
        finally:
            current_session_id.reset(token)

    async def test_execute_overwrites_hallucinated_session_id(self):
        from navi.tools._internal.base import current_session_id

        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = ("ok", False)
        tool = McpTool(
            server_name="book",
            tool_name="search",
            description="",
            parameters={},
            manager=mock_manager,
        )
        token = current_session_id.set("real-session-id")
        try:
            result = await tool.execute({"session_id": "fake-id", "query": "foo"})
            assert result.success
            args = mock_manager.call_tool.call_args[0][2]
            assert args["session_id"] == "real-session-id"
        finally:
            current_session_id.reset(token)

    async def test_execute_normalizes_navi3d_paths(self):
        from navi.tools._internal.base import current_session_id

        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = ("ok", False)
        tool = McpTool(
            server_name="navi-3d",
            tool_name="compile_scad",
            description="",
            parameters={},
            manager=mock_manager,
        )
        token = current_session_id.set("real-session-id")
        try:
            result = await tool.execute({
                "source_path": "session_files/real-session-id/falcon9_rocket.scad",
                "output_path": "session_files/real-session-id/falcon9_rocket.stl",
            })
            assert result.success
            args = mock_manager.call_tool.call_args[0][2]
            assert args["source_path"] == "falcon9_rocket.scad"
            assert args["output_path"] == "falcon9_rocket.stl"
            assert args["session_id"] == "real-session-id"
        finally:
            current_session_id.reset(token)

    async def test_execute_does_not_modify_paths_for_non_navi3d(self):
        from navi.tools._internal.base import current_session_id

        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = ("ok", False)
        tool = McpTool(
            server_name="other-server",
            tool_name="do_stuff",
            description="",
            parameters={},
            manager=mock_manager,
        )
        token = current_session_id.set("real-session-id")
        try:
            result = await tool.execute({"path": "some/nested/path.txt"})
            assert result.success
            args = mock_manager.call_tool.call_args[0][2]
            assert args["path"] == "some/nested/path.txt"
            assert args["session_id"] == "real-session-id"
        finally:
            current_session_id.reset(token)

    async def test_execute_navi_ui_extracts_metadata(self):
        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = (
            '{"output": "rendered", "metadata": {"ui_component": {"component": "card_grid", "payload": {}}}}',
            False,
        )
        tool = McpTool(
            server_name="navi_ui",
            tool_name="render_component",
            description="",
            parameters={},
            manager=mock_manager,
        )
        result = await tool.execute({"component_name": "card_grid"})
        assert result.success
        assert result.output == "rendered"
        assert result.metadata == {"ui_component": {"component": "card_grid", "payload": {}}}

    async def test_execute_navi_ui_error_prefix_is_failure(self):
        mock_manager = AsyncMock(spec=McpManager)
        mock_manager.call_tool.return_value = (
            "Error: payload must be a JSON object (dict)",
            False,
        )
        tool = McpTool(
            server_name="navi_ui",
            tool_name="render_component",
            description="",
            parameters={},
            manager=mock_manager,
        )
        result = await tool.execute({"component_name": "card_grid"})
        assert not result.success
        assert "payload must be a JSON object" in result.error