"""Tests for list_tools.
Two failures this tool must not have. It must not advertise a tool the agent
cannot call — the list is built from the profile config, but the call goes to
the live registry, and an MCP server that never connected leaves a phantom
entry the agent then calls into a "tool not found". And it must not dump the
whole catalogue: an MCP-heavy profile carries ~125 tools / ~35 KB, which is
9k tokens spent to answer "do I have anything for ssh".
"""
import pytest
from navi.core.registry import ProfileRegistry, ToolRegistry
from navi.profiles.base import ToolConfig, ToolScopeConfig
from navi.tools.list_tools import ListToolsTool
from tests.conftest_factory import FakeTool, make_profile
@pytest.fixture(autouse=True)
def no_user_enabled_tools(monkeypatch):
"""Every profile also picks up tools/enabled.json, a real file on the machine
running the tests — tests must see exactly what they declare."""
monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", lambda: [])
class FakeMcpManager:
"""Stand-in for McpManager — group name to tool names, as the server config holds them."""
def __init__(self, groups: dict[str, dict[str, list[str]]] | None = None) -> None:
self._groups = groups or {}
def resolve_group(self, server_name: str, group_name: str) -> list[str]:
return list(self._groups.get(server_name, {}).get(group_name, []))
def make_profile_with(profile_id: str, native: list[str], mcp: dict | None = None):
"""A profile holding exactly the declared tools — no enabled_tools migration on top."""
return make_profile(
profile_id,
enabled_tools=[],
tools=ToolConfig(
agent=ToolScopeConfig(native=list(native), mcp=dict(mcp or {})),
),
)
def make_profiles(*profiles) -> ProfileRegistry:
registry = ProfileRegistry()
for profile in profiles:
registry.register(profile)
return registry
def make_registry(*specs: tuple[str, str]) -> ToolRegistry:
"""specs: (name, description) pairs — the live, registered tools."""
registry = ToolRegistry()
for name, description in specs:
registry.register(FakeTool(name, description=description), builtin=True)
return registry
def make_tool(registry, profiles, groups=None) -> ListToolsTool:
return ListToolsTool(
registry=registry,
profile_registry=profiles,
mcp_manager=FakeMcpManager(groups),
)
@pytest.mark.asyncio
async def test_lists_only_the_requested_profiles_tools():
profiles = make_profiles(
make_profile_with("secretary", ["todo"]),
make_profile_with("developer", ["code_exec"]),
)
registry = make_registry(("todo", "notes"), ("code_exec", "runs"), ("weather", "forecast"))
result = await make_tool(registry, profiles).execute({"profile_id": "secretary"})
assert result.success is True
assert "todo" in result.output
assert "code_exec" not in result.output
assert "weather" not in result.output
@pytest.mark.asyncio
async def test_compact_output_groups_names_by_source():
groups = {"tgclient": {"read": ["dialogs_list", "messages_history"]}}
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"tgclient": ["read"]})
)
registry = make_registry(
("todo", "notes and tasks"),
("mcp__tgclient__dialogs_list", "list dialogs"),
("mcp__tgclient__messages_history", "read history"),
)
result = await make_tool(registry, profiles, groups).execute({"profile_id": "secretary"})
assert "native (1): todo" in result.output
assert (
"mcp__tgclient__ (2): mcp__tgclient__dialogs_list, mcp__tgclient__messages_history"
in result.output
)
# Descriptions are what makes the output huge — they stay out unless asked for.
assert "notes and tasks" not in result.output
@pytest.mark.asyncio
async def test_verbose_is_an_order_of_magnitude_larger():
"""Descriptions are what costs the context — 125 tools of them is ~35 KB."""
names = [f"tool_{i}" for i in range(20)]
profiles = make_profiles(make_profile_with("secretary", names))
registry = make_registry(*[(name, "d" * 200) for name in names])
tool = make_tool(registry, profiles)
compact = (await tool.execute({"profile_id": "secretary"})).output
verbose = (await tool.execute({"profile_id": "secretary", "verbose": True})).output
assert "d" * 200 not in compact
assert "d" * 200 in verbose
assert len(verbose) > len(compact) * 5
@pytest.mark.asyncio
async def test_query_filters_by_name_and_by_description():
profiles = make_profiles(make_profile_with("secretary", ["todo", "sync_files"]))
registry = make_registry(("todo", "notes and tasks"), ("sync_files", "copy files"))
tool = make_tool(registry, profiles)
by_name = (await tool.execute({"profile_id": "secretary", "query": "files"})).output
assert "mcp__" not in by_name
assert "sync_files" in by_name
assert "todo" not in by_name
assert "matching 'files'" in by_name
by_description = (await tool.execute({"profile_id": "secretary", "query": "tasks"})).output
assert "todo" in by_description
assert "sync_files" not in by_description
@pytest.mark.asyncio
async def test_declared_but_unregistered_tools_are_not_advertised():
"""The navi_ui case: the profile config declares it, the server never connected."""
groups = {"navi_ui": {"ui": ["render_component"]}}
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"navi_ui": ["ui"]})
)
registry = make_registry(("todo", "notes")) # render_component was never registered
result = await make_tool(registry, profiles, groups).execute({"profile_id": "secretary"})
assert "Not registered (1): mcp__navi_ui__render_component" in result.output
# Counted as neither available nor part of a source section.
assert "(1 available)" in result.output
assert "mcp__navi_ui__ (1)" not in result.output
@pytest.mark.asyncio
async def test_query_still_reports_a_matching_unregistered_tool():
""""Do I have anything for render" must answer "declared, but not callable"."""
groups = {"navi_ui": {"ui": ["render_component"]}}
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"navi_ui": ["ui"]})
)
registry = make_registry(("todo", "notes"))
result = await make_tool(registry, profiles, groups).execute(
{"profile_id": "secretary", "query": "render"}
)
assert "Not registered (1): mcp__navi_ui__render_component" in result.output
assert "0 of 2 declared" in result.output
@pytest.mark.asyncio
async def test_wildcard_group_takes_every_registered_tool_of_that_server():
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"tgclient": ["*"]})
)
registry = make_registry(
("todo", "notes"),
("mcp__tgclient__dialogs_list", "dialogs"),
("mcp__other__whatever", "not this server"),
)
result = await make_tool(registry, profiles).execute({"profile_id": "secretary"})
assert "mcp__tgclient__dialogs_list" in result.output
assert "mcp__other__whatever" not in result.output
@pytest.mark.asyncio
async def test_requires_a_profile_id():
result = await make_tool(make_registry(), make_profiles()).execute({})
assert result.success is False
assert result.error == "missing_profile_id"
@pytest.mark.asyncio
async def test_unknown_profile_is_rejected():
registry = make_registry(("todo", "notes"))
profiles = make_profiles(make_profile_with("secretary", ["todo"]))
result = await make_tool(registry, profiles).execute({"profile_id": "nope"})
assert result.success is False
assert result.error == "profile_not_found"