From 3bc1780f2d3ec3df795a3d205e84f63c0fa768a8 Mon Sep 17 00:00:00 2001 From: Muno <90378186+Muno459@users.noreply.github.com> Date: Mon, 4 May 2026 14:25:49 +0200 Subject: [PATCH] fix(toolsets): merge MCP tools when alias collides with static toolset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If an MCP server registers itself under the same name as a built-in toolset (e.g. an MCP "homeassistant" running alongside the built-in `homeassistant` toolset), `get_toolset()` returned the static definition early and the MCP tools were silently shadowed — they got registered into `mcp-homeassistant` but never surfaced when the agent looked up `homeassistant`. Detect the collision via the registry alias (`alias_target` starting with `mcp-`) and merge the two tool lists. The description is annotated so the source is visible in `/toolsets info`. --- tests/test_toolsets.py | 18 ++++++++++++++++++ toolsets.py | 10 ++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 944d96b742..daf06b469b 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -56,6 +56,24 @@ class TestGetToolset: assert set(ts["tools"]) == {"web_search", "web_extract", "web_search_plus"} + def test_static_and_mcp_alias_with_same_name_are_merged(self, monkeypatch): + # An MCP server named like a built-in toolset registers a bare alias to its + # `mcp-` toolset; the static entry must union those tools in (and keep + # its own includes) instead of shadowing the server. + TOOLSETS["_mergetest"] = {"description": "static", "tools": ["builtin_tool_a"], "includes": ["web"]} + try: + reg = ToolRegistry() + reg.register(name="mcp__mergetest_call", toolset="mcp-_mergetest", + schema=_make_schema("mcp__mergetest_call", "Call"), handler=_dummy_handler) + reg.register_toolset_alias("_mergetest", "mcp-_mergetest") + monkeypatch.setattr("tools.registry.registry", reg) + + ts = get_toolset("_mergetest") + assert {"builtin_tool_a", "mcp__mergetest_call"} <= set(ts["tools"]) + assert ts["includes"] == ["web"] + finally: + del TOOLSETS["_mergetest"] + class TestResolveToolset: def test_leaf_toolset(self): diff --git a/toolsets.py b/toolsets.py index cf7aef5c5a..ffade803fa 100644 --- a/toolsets.py +++ b/toolsets.py @@ -281,8 +281,14 @@ def get_toolset(name: str, *, include_registry: bool = True) -> Optional[Dict[st return toolset if toolset else None if toolset: - merged_tools = sorted(set(toolset.get("tools", [])) | set(registry.get_tool_names_for_toolset(name))) - return {**toolset, "tools": merged_tools} + merged_tools = set(toolset.get("tools", [])) | set(registry.get_tool_names_for_toolset(name)) + # An MCP server named like a built-in toolset ("homeassistant", "browser") registers a bare + # alias to its `mcp-` toolset; without this union the static entry shadows it and the + # server's tools never reach the model even though discovery registered them. + alias_target = registry.get_toolset_alias_target(name) + if alias_target and alias_target != name: + merged_tools |= set(registry.get_tool_names_for_toolset(alias_target)) + return {**toolset, "tools": sorted(merged_tools)} if name in _get_plugin_toolset_names(): # Plugin toolset; shown as its MCP server alias when one exists.