from __future__ import annotations
import json
from typing import Any
from pathlib import Path
from navi.config import settings
from navi.tools._internal.base import Tool, ToolContext, 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], ctx: ToolContext | None = None) -> 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 = ctx.session_id if ctx else 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",
)
# navi_ui returns a structured JSON envelope so that component metadata
# can be stored on the role="tool" message and rendered by the webclient.
metadata: dict = {}
if self.server_name == "navi_ui":
try:
parsed = json.loads(output)
if isinstance(parsed, dict):
metadata = parsed.get("metadata") or {}
output = parsed.get("output", output)
except Exception:
pass
# The navi_ui tool signals validation / usage errors with an
# "Error:" prefix. Surface them as failed tool calls so the
# agent sees them correctly and the UI card is not green.
if isinstance(output, str) and output.startswith("Error:"):
return ToolResult(
success=False,
output=output,
error=output.removeprefix("Error:").strip(),
metadata=metadata,
)
return ToolResult(success=True, output=output, metadata=metadata)
except Exception as exc:
return ToolResult(success=False, output="", error=str(exc))