"""Built-in tool that returns the list of tools available to a profile."""
from __future__ import annotations
from navi.mcp.tools import build_mcp_name
from ._internal.base import Tool, ToolContext, ToolResult, current_profile_id
NATIVE_SOURCE = "native"
SCOPE_AGENT = "agent"
SCOPE_SUBAGENT = "subagent"
_MISSING_HINT = (
"Declared for this profile but not registered — their MCP server is not "
"connected (or the tool is gone). Do not call these; pick another tool."
)
class ListToolsTool(Tool):
name = "list_tools"
description = (
"Returns the tools a profile can actually use, grouped by source: its built-in "
"tools as 'native' and each MCP server as 'mcp__<server>__'. Defaults to the "
"profile you are running as — name another with profile_id. scope picks that "
"profile's agent or subagent toolset (spawn_agent runs the latter). Names only "
"by default — narrow it with query (substring over names and descriptions) or "
"add verbose for descriptions. For one tool's full docs use tool_manual."
)
parameters = {
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": (
"Profile ID to inspect. Omit to inspect the profile you are currently "
"running as."
),
},
"scope": {
"type": "string",
"enum": [SCOPE_AGENT, SCOPE_SUBAGENT],
"description": (
"Which of that profile's two toolsets to list: 'agent' (what this "
"profile calls directly, the default) or 'subagent' (what a "
"spawn_agent sub-agent running this profile gets)."
),
},
"query": {
"type": "string",
"description": (
"Optional substring filter over tool names and descriptions, "
"e.g. 'ssh' or 'notify'. The full catalogue of an MCP-heavy profile "
"is tens of thousands of characters — filter instead of pulling it all."
),
},
"verbose": {
"type": "boolean",
"description": (
"Add each tool's description. The output grows ~10x, so use it with a "
"narrow query, not to list everything."
),
},
},
}
def __init__(
self,
registry=None,
profile_registry=None,
mcp_manager=None,
) -> None:
self._registry = registry
self._profile_registry = profile_registry
self._mcp_manager = mcp_manager
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
if self._registry is None:
return ToolResult(success=False, output="Registry not available.", error="no_registry")
profile_id = (params.get("profile_id") or "").strip()
# No profile named: report on the one this run executes as, so the common
# question ("what do I have?") needs no argument the agent may not know.
defaulted = not profile_id
if defaulted:
profile_id = (ctx.profile_id if ctx else None) or current_profile_id.get() or ""
if not profile_id:
return ToolResult(
success=False,
output=(
"No profile named and none is active in this context. Pass profile_id "
"explicitly — list_profiles shows the available IDs."
),
error="no_active_profile",
)
if self._profile_registry is None:
return ToolResult(
success=False, output="Profile registry not available.", error="no_profile_registry"
)
try:
profile = self._profile_registry.get(profile_id)
except Exception:
return ToolResult(
success=False, output=f"Profile '{profile_id}' not found.", error="profile_not_found"
)
scope = (params.get("scope") or SCOPE_AGENT).strip().lower()
if scope not in (SCOPE_AGENT, SCOPE_SUBAGENT):
return ToolResult(
success=False,
output=f"Unknown scope '{scope}'. Use '{SCOPE_AGENT}' or '{SCOPE_SUBAGENT}'.",
error="unknown_scope",
)
query = (params.get("query") or "").strip().lower()
verbose = bool(params.get("verbose"))
declared = self._declared_by_source(profile, scope)
declared_total = sum(len(names) for names in declared.values())
# Resolve against the LIVE registry, not the config: a tool the config
# declares but nothing registered (server down, tool removed) must not be
# advertised — the agent would call it and get "tool not found".
available: dict[str, list[str]] = {}
descriptions: dict[str, str] = {}
missing: list[str] = []
matched = 0
for source, names in declared.items():
kept: list[str] = []
for name in names:
tool = self._lookup(name)
if tool is None:
if not query or query in name.lower():
missing.append(name)
continue
description = tool.description or ""
if query and query not in name.lower() and query not in description.lower():
continue
kept.append(name)
descriptions[name] = description
matched += 1
if kept:
available[source] = kept
lines = [self._header(profile_id, matched, declared_total, query, scope, defaulted)]
for source in self._ordered_sources(available):
names = available[source]
if verbose:
lines.append(f"\n{source} ({len(names)})")
lines.extend(f" • {n}: {descriptions.get(n, '')}" for n in names)
else:
lines.append(f"{source} ({len(names)}): {', '.join(names)}")
if missing:
lines.append("")
lines.append(f"Not registered ({len(missing)}): {', '.join(missing)}")
lines.append(_MISSING_HINT)
if not query and not verbose:
lines.append("")
lines.append(
"Pass query=… to filter, verbose=true for descriptions, tool_manual for one tool's docs."
)
return ToolResult(success=True, output="\n".join(lines))
# ── helpers ──────────────────────────────────────────────────────────
def _declared_by_source(self, profile, scope: str = SCOPE_AGENT) -> dict[str, list[str]]:
"""Names the profile enables, bucketed by source ('native', 'mcp__<server>__')."""
scope_config = (
profile.get_subagent_tools() if scope == SCOPE_SUBAGENT else profile.get_agent_tools()
)
groups: dict[str, list[str]] = {NATIVE_SOURCE: list(scope_config.native)}
# Same reader the agent itself uses — a second, cwd-relative copy of
# this path made list_tools disagree with the real toolset.
from navi.core.tool_utils import load_user_enabled_tools
for name in load_user_enabled_tools():
if name not in groups[NATIVE_SOURCE]:
groups[NATIVE_SOURCE].append(name)
if scope_config.mcp and self._mcp_manager:
for server_name, group_names in scope_config.mcp.items():
source = build_mcp_name(server_name, "")
bucket = groups.setdefault(source, [])
if "*" in group_names:
for tool in self._registry.all():
if tool.name.startswith(source) and tool.name not in bucket:
bucket.append(tool.name)
else:
for group_name in group_names:
for tool_name in self._mcp_manager.resolve_group(server_name, group_name):
full_name = build_mcp_name(server_name, tool_name)
if full_name not in bucket:
bucket.append(full_name)
return {source: names for source, names in groups.items() if names}
def _lookup(self, name: str):
try:
return self._registry.get(name)
except Exception:
return None
@staticmethod
def _ordered_sources(available: dict[str, list[str]]) -> list[str]:
"""Native tools first, then MCP servers alphabetically."""
servers = sorted(source for source in available if source != NATIVE_SOURCE)
return ([NATIVE_SOURCE] if NATIVE_SOURCE in available else []) + servers
@staticmethod
def _header(
profile_id: str,
matched: int,
declared_total: int,
query: str,
scope: str,
defaulted: bool,
) -> str:
# Say which profile was assumed and which toolset was read — an agent that
# asked about nobody must not read a subagent list as its own tools.
notes = []
if defaulted:
notes.append("current profile")
if scope == SCOPE_SUBAGENT:
notes.append(f"{SCOPE_SUBAGENT} scope")
prefix = f"Tools for profile '{profile_id}'"
if notes:
prefix += f" ({', '.join(notes)})"
if query:
return f"{prefix} matching '{query}': {matched} of {declared_total} declared."
return f"{prefix} ({matched} available):"