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 <noreply@anthropic.com>
This commit is contained in:
Matthieu 2026-07-25 22:12:10 +02:00
parent d8a2059df8
commit 830f7cd6d7

View file

@ -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