From 830f7cd6d7cc1344f3c52346f6d6b810eb157cab Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sat, 25 Jul 2026 22:12:10 +0200 Subject: [PATCH] fix(tools): stop unusable browser tools from crowding out browser_navigate Tool retrieval returns a top-K (8 by default), and @playwright/mcp alone exposes 30 tools. Measured across three typical browsing requests on a local setup, the agent was handed browser_drop, browser_handle_dialog, browser_close, browser_console_messages and the whole xy-mouse family -- while browser_navigate was missing in two cases out of three. On "click the login button", six of the eight slots went to mouse primitives. Without the tool that opens a page, every other browser tool is dead weight. So the model answered that it could not reach the internet, and that answer was correct. It reads as a refusal or a hallucination, which is what makes it expensive to diagnose: the browser is installed, the MCP server is connected and reports 30 tools, and calling browser_navigate by hand works fine. Two guards: MCP_INDEX_DENIED keeps 18 tools out of the index -- pointer primitives (browser_click and browser_hover work off the accessibility snapshot and need no coordinates), debugging aids, session plumbing, and browser_run_code_unsafe, which runs arbitrary JavaScript in the page. They stay connected and callable; they just no longer compete for a retrieval slot. MCP_COMPANIONS pulls browser_navigate and browser_snapshot in whenever any browser tool is retrieved. The server prefix comes from the hit itself rather than a constant, so the rule holds under any server id -- verified with a second prefix. Matching is on the bare tool name, after the server prefix, so neither guard depends on the browser being registered as builtin_browser. Measured on the same three queries after the change: browser_navigate present three times out of three, no denied tool leaking through, and every slot filled with something that works -- navigate, snapshot, find, click, type, press_key, select_option. The running app indexes 12 MCP tools instead of 30, and the agent then completed a real navigate-then-read task it had previously refused. Co-Authored-By: Claude Opus 5 --- src/tool_index.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/tool_index.py b/src/tool_index.py index f3a777f6c..81d905eb7 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -61,6 +61,40 @@ ASSISTANT_ALWAYS_AVAILABLE = frozenset({ "ui_control", }) +# MCP tools that must never be indexed for retrieval. +# +# Retrieval returns a top-K (8 by default), and @playwright/mcp alone exposes +# 30 tools. In practice that means the agent is handed browser_drop, +# browser_handle_dialog or browser_close while browser_navigate — the only tool +# that opens a page — does not surface at all. The model then reports that it +# cannot browse the web, which is accurate but reads as a refusal. +# +# Matched against the bare tool name (the part after the server prefix), so +# this holds whatever server id the browser is registered under. Denied tools +# stay connected and callable; they just no longer compete for a slot. +MCP_INDEX_DENIED = frozenset({ + # Low-level pointer primitives — browser_click/browser_hover work off the + # accessibility snapshot and need no coordinates. + "browser_mouse_click_xy", "browser_mouse_down", "browser_mouse_up", + "browser_mouse_move_xy", "browser_mouse_drag_xy", "browser_mouse_wheel", + "browser_drag", "browser_drop", + # Debugging aids, not task tools. + "browser_console_messages", "browser_network_request", + "browser_network_requests", "browser_evaluate", + # Arbitrary JavaScript in the page. Deliberately out of reach. + "browser_run_code_unsafe", + # Session plumbing the agent should not spend its budget on. + "browser_close", "browser_resize", "browser_tabs", + "browser_handle_dialog", "browser_file_upload", +}) + +# Tools that are worthless on their own. If retrieval surfaces any browser +# tool, the agent also needs the two that open a page and read it back — +# otherwise it receives, say, browser_find with no way to reach a page first. +MCP_COMPANIONS = { + "browser_": ("browser_navigate", "browser_snapshot"), +} + COLLECTION_NAME = "odysseus_tool_index" # ── Tool description registry ── @@ -266,6 +300,10 @@ class ToolIndex: if len(name_desc) == 2: name = name_desc[0].strip() desc = name_desc[1].strip() + # Keep denied tools out of the index entirely, so they + # cannot crowd out the ones that do the actual work. + if name.rsplit("__", 1)[-1] in MCP_INDEX_DENIED: + continue # Include server identity in the indexed text so RAG can # distinguish "list_emails for server-a" from "list_emails for server-b" server_ctx = f" (server: {current_server})" if current_server else "" @@ -522,6 +560,16 @@ class ToolIndex: base = set(always_include or ALWAYS_AVAILABLE) retrieved = self.retrieve(query, k=k) base.update(retrieved) + # Pull in the companions of anything retrieved. The server prefix is + # taken from the hit itself rather than hardcoded, so this keeps + # working if the browser is registered under a different id. + for name in retrieved: + prefix, _, bare = name.rpartition("__") + for marker, companions in MCP_COMPANIONS.items(): + if bare.startswith(marker): + base.update( + f"{prefix}__{c}" if prefix else c for c in companions + ) # Keyword-based force-include for common intents. Match on word # boundaries, not raw substrings, so short hints like "fix", "line", # "serve", "reply" or "unread" don't fire inside unrelated words