Newer
Older
navi-1 / navi / tools / test_mcp_tool.py
"""Built-in tool to test a single MCP tool call in isolation."""

import asyncio

from ._internal.base import Tool, ToolContext, ToolResult


class TestMcpToolTool(Tool):
    name = "test_mcp_tool"
    description = (
        "Execute a single MCP tool call for testing. "
        "Pass server_name, tool_name, and arguments. "
        "Returns the raw output and whether the tool reported an error. "
        "Always use this after writing or editing an MCP server to verify "
        "each tool works before reporting success to the user. "
        "Do NOT use mcp_status for testing individual tools — mcp_status is for discovery only."
    )
    parameters = {
        "type": "object",
        "properties": {
            "server_name": {
                "type": "string",
                "description": "MCP server name as listed in mcp_servers.d/<name>.json.",
            },
            "tool_name": {
                "type": "string",
                "description": "Tool name exposed by the server (without mcp: prefix).",
            },
            "arguments": {
                "type": "object",
                "description": "Arguments dict to pass to the tool. Omit or pass {} for tools with no required params.",
            },
        },
        "required": ["server_name", "tool_name"],
    }

    def __init__(self, mcp_manager=None) -> None:
        self._mcp_manager = mcp_manager

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        server_name = (params.get("server_name") or "").strip()
        tool_name = (params.get("tool_name") or "").strip()
        arguments: dict = params.get("arguments") or {}

        if not server_name:
            return ToolResult(success=False, output="server_name is required.", error="missing server_name")
        if not tool_name:
            return ToolResult(success=False, output="tool_name is required.", error="missing tool_name")

        manager = self._mcp_manager
        if manager is None:
            # Fallback to module-level global in case startup wiring was
            # skipped because of an earlier exception in the same block.
            from navi.api.deps import _mcp_manager as _global_mcp_manager
            manager = _global_mcp_manager
        if manager is None:
            return ToolResult(
                success=False,
                output="MCP manager not available. Is the server running and MCP configured?",
                error="no manager",
            )

        # Quick check: is the server even connected?
        client = manager.clients.get(server_name)
        if client is None:
            return ToolResult(
                success=False,
                output=(
                    f"MCP server '{server_name}' is not connected.\n\n"
                    "Possible causes:\n"
                    "1. The server config file is missing from mcp_servers.d/ — run create_mcp_server again.\n"
                    "2. The server exited immediately on startup (check that main() is called at the bottom of mcp_server.py).\n"
                    "3. The server threw a traceback on startup — run `timeout 5 .venv/bin/python -m app.mcp_server` to see it.\n\n"
                    "Next steps:\n"
                    "1. Call `mcp_status` to see which servers are listed.\n"
                    "2. Inspect `mcp_servers.d/<name>.json` for correct absolute paths.\n"
                    "3. Call `reload_tools` to reconnect.\n"
                    "4. If still failing, read mcp_server.py and verify main() is called."
                ),
                error="not_connected",
            )

        try:
            output, is_error = await asyncio.wait_for(
                manager.call_tool(server_name, tool_name, arguments),
                timeout=30.0,
            )
        except asyncio.TimeoutError:
            return ToolResult(
                success=False,
                output="Tool call timed out after 30 seconds.",
                error="timeout",
            )
        except Exception as exc:
            import traceback as _tb
            hint = ""
            if "not connected" in str(exc).lower():
                hint = "\n\nHint: The server disconnected during the call. This usually means the server process crashed. Check the server code for runtime errors."
            return ToolResult(
                success=False,
                output=f"Tool call failed: {exc}{hint}\n\nTraceback:\n{_tb.format_exc()}",
                error="call failed",
            )

        if is_error:
            return ToolResult(
                success=False,
                output=f"Tool reported an error.\n\nOutput:\n{output}",
                error="tool error",
            )

        if not output or output.strip() == "":
            return ToolResult(
                success=True,
                output=(
                    "Tool returned successfully but the response was EMPTY.\n\n"
                    "This is suspicious — the tool may have an early return or silent failure. "
                    "Check the tool implementation for missing return values or empty branches."
                ),
            )

        return ToolResult(
            success=True,
            output=f"OK — tool returned successfully.\n\nOutput:\n{output}",
        )