diff --git a/mcp_servers.json b/mcp_servers.json index c74143e..2b5066f 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -26,6 +26,6 @@ "list_pending_changes" ] }, - "instructions": "MANDATORY for profiles that expose gnexus-book tools: Before answering any question about infrastructure, servers, services, networks, documentation, or system inventory, call gnexus-book tools first. In Navi, gnexus-book tools are exposed with the mcp_gnexus-book_ prefix; use the exact tool names from the current tool schema.\n\nQuery mapping:\n- 'server X not working / status' → mcp_gnexus-book_search_docs, mcp_gnexus-book_get_inventory_item\n- 'what services run where' → mcp_gnexus-book_list_inventory, mcp_gnexus-book_get_relationships\n- 'update docs / fix documentation' → mcp_gnexus-book_read_doc, mcp_gnexus-book_propose_doc_change, mcp_gnexus-book_commit_changes\n- 'is X up to date / freshness' → mcp_gnexus-book_check_freshness\n- 'validate repository / repo state' → mcp_gnexus-book_validate_repository, mcp_gnexus-book_git_status\n\nDo not rely on memory for infrastructure facts. Memory is only for personal user facts and preferences. Always pull infrastructure state from gnexus-book when these tools are available to the active profile.\n\nAlways validate the repository before making changes. Do not store raw secrets in documentation.\n\nBefore the final response, decide whether tool execution revealed stable reusable infrastructure facts, service configurations, or relationships. If yes, persist them with gnexus-book write tools (mcp_gnexus-book_propose_doc_change, mcp_gnexus-book_propose_inventory_item_change) before answering. If the fact is user-specific rather than infrastructure documentation, use the memory tool instead. Choose the target based on scope, not habit." + "instructions": "MANDATORY for profiles that expose gnexus-book tools: Before answering any question about infrastructure, servers, services, networks, documentation, or system inventory, call gnexus-book tools first.\n\nUse only gnexus-book tool names that are present in the current tool schema. In Navi they are exposed with the mcp_gnexus-book_ prefix, but each profile may expose only some groups. Do not invent or call gnexus-book tools that are not in the current tool list.\n\nQuery mapping by capability:\n- Status or facts about a server/service → search docs first, then read a specific doc or inventory item if those tools are available.\n- Service placement or topology → list inventory and relationships if available.\n- Documentation changes → read the target doc first, then propose a doc or inventory change if write tools are available.\n- Freshness questions → use freshness checks if available.\n- Repository validation/status → use repository tools only if they are available in the current tool schema; otherwise skip this step and continue with available read/write tools.\n\nDo not rely on memory for infrastructure facts. Memory is only for personal user facts and preferences. Always pull infrastructure state from gnexus-book when these tools are available to the active profile.\n\nDo not store raw secrets in documentation.\n\nBefore the final response, decide whether tool execution revealed stable reusable infrastructure facts, service configurations, or relationships. If yes and gnexus-book write tools are available, persist them before answering. If write tools are not available, report the facts that should be persisted. If the fact is user-specific rather than infrastructure documentation, use the memory tool instead. Choose the target based on scope, not habit." } } diff --git a/navi/core/tool_executor.py b/navi/core/tool_executor.py index fe4d289..3ecd938 100644 --- a/navi/core/tool_executor.py +++ b/navi/core/tool_executor.py @@ -15,6 +15,32 @@ log = structlog.get_logger() +def _resolve_tool(tool_map: dict[str, Tool], name: str) -> tuple[str, Tool | None]: + """Resolve exact tool names plus common MCP alias mistakes.""" + tool = tool_map.get(name) + if tool is not None: + return name, tool + + bare_matches = [ + (candidate_name, candidate) + for candidate_name, candidate in tool_map.items() + if candidate_name.startswith("mcp_") and candidate_name.endswith(f"_{name}") + ] + if len(bare_matches) == 1: + return bare_matches[0] + + normalized = name.replace("-", "_") + normalized_matches = [ + (candidate_name, candidate) + for candidate_name, candidate in tool_map.items() + if candidate_name.startswith("mcp_") and candidate_name.replace("-", "_") == normalized + ] + if len(normalized_matches) == 1: + return normalized_matches[0] + + return name, None + + class ToolExecutor: """Runs tool calls and builds ToolEvent / Message results.""" @@ -33,7 +59,7 @@ """ from navi.core.events import ToolEvent - tool = tool_map.get(tc.name) + resolved_name, tool = _resolve_tool(tool_map, tc.name) image_msg = None metadata: dict = {} if tool is None: @@ -41,16 +67,16 @@ event = ToolEvent(tool_name=tc.name, arguments=tc.arguments, result=content, success=False) else: - log.info("tool.execute", tool=tc.name, args=tc.arguments) + log.info("tool.execute", tool=resolved_name, requested_tool=tc.name, args=tc.arguments) middlewares = getattr(self._tools, "_middlewares", []) for mw in middlewares: - await mw.before_execute(tc.name, tc.arguments) + await mw.before_execute(resolved_name, tc.arguments) result = await tool.execute(tc.arguments) for mw in middlewares: - await mw.after_execute(tc.name, tc.arguments, result) + await mw.after_execute(resolved_name, tc.arguments, result) content = result.to_message_content() metadata = result.metadata or {} - event = ToolEvent(tool_name=tc.name, arguments=tc.arguments, + event = ToolEvent(tool_name=resolved_name, arguments=tc.arguments, result=content, success=result.success, metadata=metadata) if result.success and result.metadata and result.metadata.get("is_image"): @@ -58,11 +84,11 @@ if b64: image_msg = Message( role="user", - content=f"[Image loaded via {tc.name} — analyse it]", + content=f"[Image loaded via {resolved_name} — analyse it]", images=[b64], ) msg = Message(role="tool", content=content, tool_call_id=tc.id, - name=tc.name, metadata=metadata) + name=resolved_name if tool is not None else tc.name, metadata=metadata) return event, msg, image_msg async def _execute_tool_calls( @@ -71,19 +97,19 @@ tool_map = {t.name: t for t in tools} async def _run_one(tc: ToolCallRequest) -> tuple[Message, Message | None]: - tool = tool_map.get(tc.name) + resolved_name, tool = _resolve_tool(tool_map, tc.name) image_msg = None metadata: dict = {} if tool is None: content = f"Error: tool '{tc.name}' not found." else: - log.info("tool.execute", tool=tc.name, args=tc.arguments) + log.info("tool.execute", tool=resolved_name, requested_tool=tc.name, args=tc.arguments) middlewares = getattr(self._tools, "_middlewares", []) for mw in middlewares: - await mw.before_execute(tc.name, tc.arguments) + await mw.before_execute(resolved_name, tc.arguments) result = await tool.execute(tc.arguments) for mw in middlewares: - await mw.after_execute(tc.name, tc.arguments, result) + await mw.after_execute(resolved_name, tc.arguments, result) content = result.to_message_content() metadata = result.metadata or {} if result.success and result.metadata and result.metadata.get("is_image"): @@ -91,10 +117,10 @@ if b64: image_msg = Message( role="user", - content=f"[Image loaded via {tc.name} — analyse it]", + content=f"[Image loaded via {resolved_name} — analyse it]", images=[b64], ) - tool_msg = Message(role="tool", content=content, tool_call_id=tc.id, name=tc.name, metadata=metadata) + tool_msg = Message(role="tool", content=content, tool_call_id=tc.id, name=resolved_name if tool is not None else tc.name, metadata=metadata) return tool_msg, image_msg pairs = await asyncio.gather(*[_run_one(tc) for tc in tool_calls]) @@ -111,7 +137,7 @@ middlewares = getattr(self._tools, "_middlewares", []) async def _run_one(tc: ToolCallRequest) -> tuple[ToolEvent, Message, Message | None]: - tool = tool_map.get(tc.name) + resolved_name, tool = _resolve_tool(tool_map, tc.name) image_msg = None metadata: dict = {} if tool is None: @@ -120,16 +146,16 @@ tool_name=tc.name, arguments=tc.arguments, result=content, success=False ) else: - log.info("tool.execute", tool=tc.name, args=tc.arguments) + log.info("tool.execute", tool=resolved_name, requested_tool=tc.name, args=tc.arguments) for mw in middlewares: - await mw.before_execute(tc.name, tc.arguments) + await mw.before_execute(resolved_name, tc.arguments) result = await tool.execute(tc.arguments) for mw in middlewares: - await mw.after_execute(tc.name, tc.arguments, result) + await mw.after_execute(resolved_name, tc.arguments, result) content = result.to_message_content() metadata = result.metadata or {} event = ToolEvent( - tool_name=tc.name, + tool_name=resolved_name, arguments=tc.arguments, result=content, success=result.success, @@ -140,10 +166,10 @@ if b64: image_msg = Message( role="user", - content=f"[Image loaded via {tc.name} — analyse it]", + content=f"[Image loaded via {resolved_name} — analyse it]", images=[b64], ) - msg = Message(role="tool", content=content, tool_call_id=tc.id, name=tc.name, metadata=metadata) + msg = Message(role="tool", content=content, tool_call_id=tc.id, name=resolved_name if tool is not None else tc.name, metadata=metadata) return event, msg, image_msg triples = await asyncio.gather(*[_run_one(tc) for tc in tool_calls]) diff --git a/tests/unit/core/test_tool_executor.py b/tests/unit/core/test_tool_executor.py new file mode 100644 index 0000000..8dcc719 --- /dev/null +++ b/tests/unit/core/test_tool_executor.py @@ -0,0 +1,38 @@ +"""Unit tests for navi.core.tool_executor.""" + +from navi.core.registry import ToolRegistry +from navi.core.tool_executor import ToolExecutor +from navi.llm.base import ToolCallRequest +from tests.conftest_factory import FakeTool + + +class TestToolExecutorMcpAliases: + async def test_executes_bare_mcp_tool_alias(self): + registry = ToolRegistry() + tool = FakeTool("mcp_gnexus-book_search_docs") + registry.register(tool, builtin=True) + executor = ToolExecutor(registry) + + messages, images = await executor._execute_tool_calls( + [ToolCallRequest(id="1", name="search_docs", arguments={"query": "git"})], + [tool], + ) + + assert images == [] + assert messages[0].name == "mcp_gnexus-book_search_docs" + assert messages[0].content == "executed mcp_gnexus-book_search_docs" + + async def test_executes_mcp_tool_alias_with_underscore_server_name(self): + registry = ToolRegistry() + tool = FakeTool("mcp_gnexus-book_search_docs") + registry.register(tool, builtin=True) + executor = ToolExecutor(registry) + + messages, images = await executor._execute_tool_calls( + [ToolCallRequest(id="1", name="mcp_gnexus_book_search_docs", arguments={"query": "git"})], + [tool], + ) + + assert images == [] + assert messages[0].name == "mcp_gnexus-book_search_docs" + assert messages[0].content == "executed mcp_gnexus-book_search_docs"