"""Tool list construction and name resolution shared across the core.
`build_tool_list` is what the Agent and the SubAgentRunner use to turn a
profile's config into concrete tools; `resolve_tool` is how a model-written
tool name finds one of them. Both live here so every consumer — the executor
and tool_manual — answers "which tool did the model mean" the same way.
"""
import logging
from pathlib import Path
from navi.config import settings
from navi.tools._internal.base import Tool
log = logging.getLogger(__name__)
_USER_ENABLED_FILE = Path(settings.tools_dir) / "enabled.json"
def load_user_enabled_tools() -> list[str]:
try:
import json
return json.loads(_USER_ENABLED_FILE.read_text())
except Exception:
return []
def resolve_tool(tool_map: dict[str, Tool], name: str) -> tuple[str, Tool | None]:
"""Resolve a requested name against a tool map, tolerating the ways models
mangle MCP names.
Returns the key to report ('requested name' when nothing matched) and the
tool. The variants below are all observed in stored sessions: a bare tool
name, a dash/underscore swap, the old `mcp_<server>_<tool>` spelling, and
the legacy `mcp:server:tool` one. Every consumer must share this, or the
same call resolves in the executor and 404s in the tool that documents it.
"""
from navi.mcp.tools import is_mcp_tool, parse_mcp_name
tool = tool_map.get(name)
if tool is not None:
return name, tool
# Support bare tool name when the full MCP name ends with it
# e.g. "web_search" -> "mcp__navi-web__web_search"
bare_matches = [
(candidate_name, candidate)
for candidate_name, candidate in tool_map.items()
if is_mcp_tool(candidate_name) and candidate_name.endswith(f"__{name}")
]
if len(bare_matches) == 1:
return bare_matches[0]
# Normalized variant (dash vs underscore)
normalized = name.replace("-", "_")
normalized_matches = [
(candidate_name, candidate)
for candidate_name, candidate in tool_map.items()
if is_mcp_tool(candidate_name) and candidate_name.replace("-", "_") == normalized
]
if len(normalized_matches) == 1:
return normalized_matches[0]
# Fallback: old underscore format like mcp_server_tool -> mcp__server__tool
old_format_matches = [
(candidate_name, candidate)
for candidate_name, candidate in tool_map.items()
if is_mcp_tool(candidate_name) and name.startswith("mcp_")
]
for candidate_name, candidate in old_format_matches:
parsed = parse_mcp_name(candidate_name)
if parsed is None:
continue
server_name, tool_name = parsed
# mcp_navi_web_search -> navi_web_search -> split into navi, web, search
# We try matching by removing the mcp_ prefix and comparing
expected_old = f"mcp_{server_name}_{tool_name}"
if expected_old == name:
return candidate_name, candidate
# Extra fallback: legacy colon format mcp:server:tool (from old sessions)
legacy_matches = [
(candidate_name, candidate)
for candidate_name, candidate in tool_map.items()
if is_mcp_tool(candidate_name) and name.replace(":", "__") == candidate_name
]
if len(legacy_matches) == 1:
return legacy_matches[0]
return name, None
def build_tool_list(
enabled: list[str],
mcp_servers: dict[str, list[str]] | None,
tool_registry,
mcp_manager,
) -> list[Tool]:
"""Resolve enabled tool names + MCP groups into concrete Tool objects."""
names = list(enabled)
extra = load_user_enabled_tools()
for name in extra:
if name not in names:
names.append(name)
# Expand MCP server groups into concrete tool names
from navi.mcp.tools import build_mcp_name
if mcp_servers and mcp_manager:
for server_name, groups in mcp_servers.items():
if "*" in groups:
prefix = build_mcp_name(server_name, "")
for tool in tool_registry.all():
if tool.name.startswith(prefix) and tool.name not in names:
names.append(tool.name)
else:
for group_name in groups:
for tool_name in mcp_manager.resolve_group(server_name, group_name):
full_name = build_mcp_name(server_name, tool_name)
if full_name not in names:
names.append(full_name)
result = []
missing = []
for name in names:
try:
result.append(tool_registry.get(name))
except Exception:
missing.append(name)
if missing:
log.warning("build_tool_list missing tools: %s", missing)
return result