Newer
Older
navi-1 / navi / mcp / tools.py
from __future__ import annotations

from typing import Any

from pathlib import Path

from navi.config import settings
from navi.tools._internal.base import Tool, ToolResult, current_session_id

from .manager import McpManager

# Single source of truth for MCP tool naming.
_MCP_SEP = "__"


def build_mcp_name(server_name: str, tool_name: str) -> str:
    """Return the canonical internal name for an MCP tool.

    Format: ``mcp__<server>__<tool>`` — avoids colons which confuse some LLMs.
    """
    return f"mcp{_MCP_SEP}{server_name}{_MCP_SEP}{tool_name}"


def parse_mcp_name(name: str) -> tuple[str, str] | None:
    """Parse an MCP tool name into (server_name, tool_name).

    Returns ``None`` if *name* is not a valid MCP tool name.
    """
    prefix = f"mcp{_MCP_SEP}"
    if not name.startswith(prefix):
        return None
    rest = name[len(prefix) :]
    parts = rest.split(_MCP_SEP, 1)
    if len(parts) != 2:
        return None
    return parts[0], parts[1]


def is_mcp_tool(name: str) -> bool:
    return name.startswith(f"mcp{_MCP_SEP}")


class McpTool(Tool):
    """A :class:`Tool` proxy that forwards execution to an MCP server.

    The name is ``mcp__<server>__<tool>`` to avoid collisions with built-in
    and user-defined tools.
    """

    def __init__(
        self,
        server_name: str,
        tool_name: str,
        description: str,
        parameters: dict[str, Any],
        manager: McpManager,
    ) -> None:
        self.server_name = server_name
        self.tool_name = tool_name
        self.description = description
        self.parameters = parameters
        self._manager = manager
        self.name = build_mcp_name(server_name, tool_name)

    @staticmethod
    def _normalize_path_param(value: str) -> str:
        """Strip session-files prefix so MCP servers receive bare filenames.

        When the LLM passes a full relative path like
        ``session_files/<sid>/falcon9_rocket.scad`` the MCP server would
        resolve it a second time, creating double nesting.  We keep only
        the basename.
        """
        p = Path(value)
        # If it looks like a nested path, take just the filename
        if len(p.parts) > 1:
            return p.name
        return value

    async def execute(self, params: dict[str, Any]) -> ToolResult:
        # Defensive copy — never mutate the caller's dict
        forwarded = dict(params)

        # 1. Force the real session_id from the agent context so the LLM
        #    cannot hallucinate a wrong UUID (ghost-session bug).
        sid = current_session_id.get()
        if sid is not None:
            forwarded["session_id"] = sid

        # 2. For navi-3d, normalize source/output paths to bare filenames
        #    to prevent double path nesting.
        if self.server_name == "navi-3d":
            for key in ("source_path", "output_path"):
                if key in forwarded:
                    forwarded[key] = self._normalize_path_param(forwarded[key])

        try:
            output, is_error = await self._manager.call_tool(
                self.server_name, self.tool_name, forwarded
            )
            if is_error:
                return ToolResult(
                    success=False,
                    output=output,
                    error="MCP tool reported an error",
                )
            return ToolResult(success=True, output=output)
        except Exception as exc:
            return ToolResult(success=False, output="", error=str(exc))