feat(mcp): adopt mcp__server__tool naming convention

Port from anomalyco/opencode#33533. Native MCP tools now register as
mcp__<server>__<tool> (double-underscore delimiter) instead of
mcp_<server>_<tool>, aligning with the convention used by Claude Code,
Codex, and OpenCode.

The double-underscore delimiter disambiguates the server/tool boundary
even when either component contains underscores (the single-underscore
form was ambiguous, which is why is_mcp_tool_parallel_safe already had to
track provenance in a side-map). It also unifies native registration with
the Anthropic-OAuth wire form (_MCP_TOOL_PREFIX = 'mcp__'), so the
single->double promotion that path performed is now a no-op for native
tools while still handling legacy replayed names.

- tools/mcp_tool.py: add MCP_TOOL_NAME_PREFIX + mcp_prefixed_tool_name()
  helper; route _convert_mcp_schema, utility schemas, refresh stale-set,
  and the parallel-safe prefix gate through it
- agent/transports/codex_event_projector.py: mirror convention in the
  deterministic call_id input for MCP server-executed tool calls
- tests: update produced-name assertions to the new convention
fix/verification-admin-route-recovery
teknium1 2026-06-25 17:08:44 -07:00 committed by Teknium
parent 5986cdd380
commit e01f58ff1f
5 changed files with 171 additions and 152 deletions

View File

@ -217,7 +217,9 @@ class CodexEventProjector:
def _project_mcp_tool_call(self, item: dict, item_id: str) -> ProjectionResult: def _project_mcp_tool_call(self, item: dict, item_id: str) -> ProjectionResult:
server = item.get("server") or "mcp" server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown" tool = item.get("tool") or "unknown"
call_id = _deterministic_call_id(f"mcp_{server}_{tool}", item_id) # Mirror the native MCP tool-name convention (mcp__server__tool) so the
# deterministic call_id input stays consistent with registration names.
call_id = _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
args = item.get("arguments") or {} args = item.get("arguments") or {}
if not isinstance(args, dict): if not isinstance(args, dict):
args = {"arguments": args} args = {"arguments": args}

View File

@ -133,9 +133,9 @@ class TestValidateToolset:
def test_mcp_alias_uses_live_registry(self, monkeypatch): def test_mcp_alias_uses_live_registry(self, monkeypatch):
reg = ToolRegistry() reg = ToolRegistry()
reg.register( reg.register(
name="mcp_dynserver_ping", name="mcp__dynserver__ping",
toolset="mcp-dynserver", toolset="mcp-dynserver",
schema=_make_schema("mcp_dynserver_ping", "Ping"), schema=_make_schema("mcp__dynserver__ping", "Ping"),
handler=_dummy_handler, handler=_dummy_handler,
) )
reg.register_toolset_alias("dynserver", "mcp-dynserver") reg.register_toolset_alias("dynserver", "mcp-dynserver")
@ -144,7 +144,7 @@ class TestValidateToolset:
assert validate_toolset("dynserver") is True assert validate_toolset("dynserver") is True
assert validate_toolset("mcp-dynserver") is True assert validate_toolset("mcp-dynserver") is True
assert "mcp_dynserver_ping" in resolve_toolset("dynserver") assert "mcp__dynserver__ping" in resolve_toolset("dynserver")
class TestGetToolsetInfo: class TestGetToolsetInfo:

View File

@ -30,10 +30,10 @@ class TestRegisterServerTools:
with patch("tools.registry.registry", mock_registry): with patch("tools.registry.registry", mock_registry):
registered = _register_server_tools("my_srv", server, {}) registered = _register_server_tools("my_srv", server, {})
assert "mcp_my_srv_my_tool" in registered assert "mcp__my_srv__my_tool" in registered
assert "mcp_my_srv_my_tool" in mock_registry.get_all_tool_names() assert "mcp__my_srv__my_tool" in mock_registry.get_all_tool_names()
assert validate_toolset("my_srv") is True assert validate_toolset("my_srv") is True
assert "mcp_my_srv_my_tool" in resolve_toolset("my_srv") assert "mcp__my_srv__my_tool" in resolve_toolset("my_srv")
class TestRefreshTools: class TestRefreshTools:
@ -53,11 +53,11 @@ class TestRefreshTools:
# Seed initial state: one old tool registered # Seed initial state: one old tool registered
mock_registry.register( mock_registry.register(
name="mcp_live_srv_old_tool", toolset="mcp-live_srv", schema={}, name="mcp__live_srv__old_tool", toolset="mcp-live_srv", schema={},
handler=lambda x: x, check_fn=lambda: True, is_async=False, handler=lambda x: x, check_fn=lambda: True, is_async=False,
description="", emoji="", description="", emoji="",
) )
server._registered_tool_names = ["mcp_live_srv_old_tool"] server._registered_tool_names = ["mcp__live_srv__old_tool"]
# New tool list from server # New tool list from server
new_tool = _make_mcp_tool("new_tool", "new behavior") new_tool = _make_mcp_tool("new_tool", "new behavior")
@ -69,11 +69,11 @@ class TestRefreshTools:
with patch("tools.registry.registry", mock_registry): with patch("tools.registry.registry", mock_registry):
await server._refresh_tools() await server._refresh_tools()
assert "mcp_live_srv_old_tool" not in mock_registry.get_all_tool_names() assert "mcp__live_srv__old_tool" not in mock_registry.get_all_tool_names()
assert "mcp_live_srv_old_tool" not in resolve_toolset("live_srv") assert "mcp__live_srv__old_tool" not in resolve_toolset("live_srv")
assert "mcp_live_srv_new_tool" in mock_registry.get_all_tool_names() assert "mcp__live_srv__new_tool" in mock_registry.get_all_tool_names()
assert "mcp_live_srv_new_tool" in resolve_toolset("live_srv") assert "mcp__live_srv__new_tool" in resolve_toolset("live_srv")
assert server._registered_tool_names == ["mcp_live_srv_new_tool"] assert server._registered_tool_names == ["mcp__live_srv__new_tool"]
class TestMessageHandler: class TestMessageHandler:

View File

@ -183,7 +183,7 @@ class TestSchemaConversion:
mcp_tool = _make_mcp_tool(name="read_file", description="Read a file") mcp_tool = _make_mcp_tool(name="read_file", description="Read a file")
schema = _convert_mcp_schema("filesystem", mcp_tool) schema = _convert_mcp_schema("filesystem", mcp_tool)
assert schema["name"] == "mcp_filesystem_read_file" assert schema["name"] == "mcp__filesystem__read_file"
assert schema["description"] == "Read a file" assert schema["description"] == "Read a file"
assert "properties" in schema["parameters"] assert "properties" in schema["parameters"]
@ -499,7 +499,7 @@ class TestSchemaConversion:
bare_tool = types.SimpleNamespace(name="probe", description="Probe") bare_tool = types.SimpleNamespace(name="probe", description="Probe")
schema = _convert_mcp_schema("srv", bare_tool) schema = _convert_mcp_schema("srv", bare_tool)
assert schema["name"] == "mcp_srv_probe" assert schema["name"] == "mcp__srv__probe"
assert schema["parameters"] == {"type": "object", "properties": {}} assert schema["parameters"] == {"type": "object", "properties": {}}
def test_convert_mcp_schema_with_none_inputschema(self): def test_convert_mcp_schema_with_none_inputschema(self):
@ -521,7 +521,7 @@ class TestSchemaConversion:
mcp_tool = _make_mcp_tool(name="list_dir") mcp_tool = _make_mcp_tool(name="list_dir")
schema = _convert_mcp_schema("my_server", mcp_tool) schema = _convert_mcp_schema("my_server", mcp_tool)
assert schema["name"] == "mcp_my_server_list_dir" assert schema["name"] == "mcp__my_server__list_dir"
def test_hyphens_sanitized_to_underscores(self): def test_hyphens_sanitized_to_underscores(self):
"""Hyphens in tool/server names are replaced with underscores for LLM compat.""" """Hyphens in tool/server names are replaced with underscores for LLM compat."""
@ -530,7 +530,7 @@ class TestSchemaConversion:
mcp_tool = _make_mcp_tool(name="get-sum") mcp_tool = _make_mcp_tool(name="get-sum")
schema = _convert_mcp_schema("my-server", mcp_tool) schema = _convert_mcp_schema("my-server", mcp_tool)
assert schema["name"] == "mcp_my_server_get_sum" assert schema["name"] == "mcp__my_server__get_sum"
assert "-" not in schema["name"] assert "-" not in schema["name"]
@ -859,10 +859,10 @@ class TestDiscoverAndRegister:
_discover_and_register_server("fs", {"command": "npx", "args": []}) _discover_and_register_server("fs", {"command": "npx", "args": []})
) )
assert "mcp_fs_read_file" in registered assert "mcp__fs__read_file" in registered
assert "mcp_fs_write_file" in registered assert "mcp__fs__write_file" in registered
assert "mcp_fs_read_file" in mock_registry.get_all_tool_names() assert "mcp__fs__read_file" in mock_registry.get_all_tool_names()
assert "mcp_fs_write_file" in mock_registry.get_all_tool_names() assert "mcp__fs__write_file" in mock_registry.get_all_tool_names()
_servers.pop("fs", None) _servers.pop("fs", None)
@ -890,8 +890,8 @@ class TestDiscoverAndRegister:
assert validate_toolset("myserver") is True assert validate_toolset("myserver") is True
assert validate_toolset("mcp-myserver") is True assert validate_toolset("mcp-myserver") is True
assert "mcp_myserver_ping" in resolve_toolset("myserver") assert "mcp__myserver__ping" in resolve_toolset("myserver")
assert "mcp_myserver_ping" in resolve_toolset("mcp-myserver") assert "mcp__myserver__ping" in resolve_toolset("mcp-myserver")
_servers.pop("myserver", None) _servers.pop("myserver", None)
@ -916,9 +916,9 @@ class TestDiscoverAndRegister:
_discover_and_register_server("srv", {"command": "test"}) _discover_and_register_server("srv", {"command": "test"})
) )
entry = mock_registry._tools.get("mcp_srv_do_thing") entry = mock_registry._tools.get("mcp__srv__do_thing")
assert entry is not None assert entry is not None
assert entry.schema["name"] == "mcp_srv_do_thing" assert entry.schema["name"] == "mcp__srv__do_thing"
assert "parameters" in entry.schema assert "parameters" in entry.schema
assert entry.is_async is False assert entry.is_async is False
assert entry.toolset == "mcp-srv" assert entry.toolset == "mcp-srv"
@ -999,7 +999,7 @@ class TestMCPServerTask:
server = MCPServerTask("srv") server = MCPServerTask("srv")
server._config = {"command": "test"} server._config = {"command": "test"}
server._tools = [_make_mcp_tool("old"), _make_mcp_tool("keep")] server._tools = [_make_mcp_tool("old"), _make_mcp_tool("keep")]
server._registered_tool_names = ["mcp_srv_old", "mcp_srv_keep"] server._registered_tool_names = ["mcp__srv__old", "mcp__srv__keep"]
server.session = MagicMock() server.session = MagicMock()
server.session.list_tools = AsyncMock( server.session.list_tools = AsyncMock(
return_value=SimpleNamespace(tools=[_make_mcp_tool("keep"), _make_mcp_tool("new")]) return_value=SimpleNamespace(tools=[_make_mcp_tool("keep"), _make_mcp_tool("new")])
@ -1007,31 +1007,31 @@ class TestMCPServerTask:
with patch("tools.registry.registry", mock_registry): with patch("tools.registry.registry", mock_registry):
mock_registry.register( mock_registry.register(
name="mcp_srv_old", name="mcp__srv__old",
toolset="mcp-srv", toolset="mcp-srv",
schema={"name": "mcp_srv_old", "description": "Old"}, schema={"name": "mcp__srv__old", "description": "Old"},
handler=lambda *_args, **_kwargs: "{}", handler=lambda *_args, **_kwargs: "{}",
) )
mock_registry.register( mock_registry.register(
name="mcp_srv_keep", name="mcp__srv__keep",
toolset="mcp-srv", toolset="mcp-srv",
schema={"name": "mcp_srv_keep", "description": "Keep"}, schema={"name": "mcp__srv__keep", "description": "Keep"},
handler=lambda *_args, **_kwargs: "{}", handler=lambda *_args, **_kwargs: "{}",
) )
asyncio.run(server._refresh_tools()) asyncio.run(server._refresh_tools())
names = mock_registry.get_all_tool_names() names = mock_registry.get_all_tool_names()
assert "mcp_srv_old" not in names assert "mcp__srv__old" not in names
assert "mcp_srv_keep" in names assert "mcp__srv__keep" in names
assert "mcp_srv_new" in names assert "mcp__srv__new" in names
assert set(server._registered_tool_names) == { assert set(server._registered_tool_names) == {
"mcp_srv_keep", "mcp__srv__keep",
"mcp_srv_new", "mcp__srv__new",
"mcp_srv_list_resources", "mcp__srv__list_resources",
"mcp_srv_read_resource", "mcp__srv__read_resource",
"mcp_srv_list_prompts", "mcp__srv__list_prompts",
"mcp_srv_get_prompt", "mcp__srv__get_prompt",
} }
def test_schedule_tools_refresh_keeps_task_until_done(self): def test_schedule_tools_refresh_keeps_task_until_done(self):
@ -1182,11 +1182,11 @@ class TestToolsetInjection:
from tools.mcp_tool import discover_mcp_tools from tools.mcp_tool import discover_mcp_tools
result = discover_mcp_tools() result = discover_mcp_tools()
assert "mcp_fs_list_files" in result assert "mcp__fs__list_files" in result
assert validate_toolset("fs") is True assert validate_toolset("fs") is True
assert validate_toolset("mcp-fs") is True assert validate_toolset("mcp-fs") is True
assert "mcp_fs_list_files" in resolve_toolset("fs") assert "mcp__fs__list_files" in resolve_toolset("fs")
assert "mcp_fs_list_files" in resolve_toolset("mcp-fs") assert "mcp__fs__list_files" in resolve_toolset("mcp-fs")
def test_server_toolset_skips_builtin_collision(self): def test_server_toolset_skips_builtin_collision(self):
"""MCP raw aliases never overwrite a built-in toolset name.""" """MCP raw aliases never overwrite a built-in toolset name."""
@ -1222,9 +1222,9 @@ class TestToolsetInjection:
discover_mcp_tools() discover_mcp_tools()
assert fake_toolsets["terminal"]["description"] == "Terminal tools" assert fake_toolsets["terminal"]["description"] == "Terminal tools"
assert "mcp_terminal_run" not in resolve_toolset("terminal") assert "mcp__terminal__run" not in resolve_toolset("terminal")
assert validate_toolset("mcp-terminal") is True assert validate_toolset("mcp-terminal") is True
assert "mcp_terminal_run" in resolve_toolset("mcp-terminal") assert "mcp__terminal__run" in resolve_toolset("mcp-terminal")
def test_server_connection_failure_skipped(self): def test_server_connection_failure_skipped(self):
"""If one server fails to connect, others still proceed.""" """If one server fails to connect, others still proceed."""
@ -1262,8 +1262,8 @@ class TestToolsetInjection:
from tools.mcp_tool import discover_mcp_tools from tools.mcp_tool import discover_mcp_tools
result = discover_mcp_tools() result = discover_mcp_tools()
assert "mcp_good_ping" in result assert "mcp__good__ping" in result
assert "mcp_broken_ping" not in result assert "mcp__broken__ping" not in result
assert call_count == 2 assert call_count == 2
def test_partial_failure_retry_on_second_call(self): def test_partial_failure_retry_on_second_call(self):
@ -1305,8 +1305,8 @@ class TestToolsetInjection:
# First call: good connects, broken fails # First call: good connects, broken fails
result1 = discover_mcp_tools() result1 = discover_mcp_tools()
assert "mcp_good_ping" in result1 assert "mcp__good__ping" in result1
assert "mcp_broken_ping" not in result1 assert "mcp__broken__ping" not in result1
first_attempts = call_count first_attempts = call_count
# "Fix" the broken server # "Fix" the broken server
@ -1315,8 +1315,8 @@ class TestToolsetInjection:
# Second call: should retry broken, skip good # Second call: should retry broken, skip good
result2 = discover_mcp_tools() result2 = discover_mcp_tools()
assert "mcp_good_ping" in result2 assert "mcp__good__ping" in result2
assert "mcp_broken_ping" in result2 assert "mcp__broken__ping" in result2
assert call_count == 1 # Only broken retried assert call_count == 1 # Only broken retried
@ -1384,10 +1384,10 @@ class TestShutdown:
_servers.clear() _servers.clear()
registry.register( registry.register(
name="mcp_test_ping", name="mcp__test__ping",
toolset="mcp-test", toolset="mcp-test",
schema={ schema={
"name": "mcp_test_ping", "name": "mcp__test__ping",
"description": "Ping", "description": "Ping",
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
}, },
@ -1396,19 +1396,19 @@ class TestShutdown:
registry.register_toolset_alias("test", "mcp-test") registry.register_toolset_alias("test", "mcp-test")
server = MCPServerTask("test") server = MCPServerTask("test")
server._registered_tool_names = ["mcp_test_ping"] server._registered_tool_names = ["mcp__test__ping"]
_servers["test"] = server _servers["test"] = server
mcp_mod._ensure_mcp_loop() mcp_mod._ensure_mcp_loop()
try: try:
assert validate_toolset("test") is True assert validate_toolset("test") is True
assert "mcp_test_ping" in resolve_toolset("test") assert "mcp__test__ping" in resolve_toolset("test")
shutdown_mcp_servers() shutdown_mcp_servers()
finally: finally:
mcp_mod._mcp_loop = None mcp_mod._mcp_loop = None
mcp_mod._mcp_thread = None mcp_mod._mcp_thread = None
assert "mcp_test_ping" not in registry.get_all_tool_names() assert "mcp__test__ping" not in registry.get_all_tool_names()
assert validate_toolset("test") is False assert validate_toolset("test") is False
def test_shutdown_handles_errors(self): def test_shutdown_handles_errors(self):
@ -2084,10 +2084,10 @@ class TestUtilitySchemas:
schemas = _build_utility_schemas("myserver") schemas = _build_utility_schemas("myserver")
assert len(schemas) == 4 assert len(schemas) == 4
names = [s["schema"]["name"] for s in schemas] names = [s["schema"]["name"] for s in schemas]
assert "mcp_myserver_list_resources" in names assert "mcp__myserver__list_resources" in names
assert "mcp_myserver_read_resource" in names assert "mcp__myserver__read_resource" in names
assert "mcp_myserver_list_prompts" in names assert "mcp__myserver__list_prompts" in names
assert "mcp_myserver_get_prompt" in names assert "mcp__myserver__get_prompt" in names
def test_hyphens_sanitized_in_utility_names(self): def test_hyphens_sanitized_in_utility_names(self):
from tools.mcp_tool import _build_utility_schemas from tools.mcp_tool import _build_utility_schemas
@ -2096,7 +2096,7 @@ class TestUtilitySchemas:
names = [s["schema"]["name"] for s in schemas] names = [s["schema"]["name"] for s in schemas]
for name in names: for name in names:
assert "-" not in name assert "-" not in name
assert "mcp_my_server_list_resources" in names assert "mcp__my_server__list_resources" in names
def test_list_resources_schema_no_required_params(self): def test_list_resources_schema_no_required_params(self):
from tools.mcp_tool import _build_utility_schemas from tools.mcp_tool import _build_utility_schemas
@ -2419,11 +2419,11 @@ class TestUtilityToolRegistration:
) )
# Regular tool + 4 utility tools # Regular tool + 4 utility tools
assert "mcp_fs_read_file" in registered assert "mcp__fs__read_file" in registered
assert "mcp_fs_list_resources" in registered assert "mcp__fs__list_resources" in registered
assert "mcp_fs_read_resource" in registered assert "mcp__fs__read_resource" in registered
assert "mcp_fs_list_prompts" in registered assert "mcp__fs__list_prompts" in registered
assert "mcp_fs_get_prompt" in registered assert "mcp__fs__get_prompt" in registered
assert len(registered) == 5 assert len(registered) == 5
# All in the registry # All in the registry
@ -2454,8 +2454,8 @@ class TestUtilityToolRegistration:
) )
# Check that utility tools are in the right toolset # Check that utility tools are in the right toolset
for tool_name in ["mcp_myserv_list_resources", "mcp_myserv_read_resource", for tool_name in ["mcp__myserv__list_resources", "mcp__myserv__read_resource",
"mcp_myserv_list_prompts", "mcp_myserv_get_prompt"]: "mcp__myserv__list_prompts", "mcp__myserv__get_prompt"]:
entry = mock_registry._tools.get(tool_name) entry = mock_registry._tools.get(tool_name)
assert entry is not None, f"{tool_name} not found in registry" assert entry is not None, f"{tool_name} not found in registry"
assert entry.toolset == "mcp-myserv" assert entry.toolset == "mcp-myserv"
@ -2482,7 +2482,7 @@ class TestUtilityToolRegistration:
_discover_and_register_server("chk", {"command": "test"}) _discover_and_register_server("chk", {"command": "test"})
) )
entry = mock_registry._tools.get("mcp_chk_list_resources") entry = mock_registry._tools.get("mcp__chk__list_resources")
assert entry is not None assert entry is not None
# Server is connected, check_fn should return True # Server is connected, check_fn should return True
assert entry.check_fn() is True assert entry.check_fn() is True
@ -3407,12 +3407,12 @@ class TestDiscoveryFailedCount:
server.session = MagicMock() server.session = MagicMock()
server._tools = [_make_mcp_tool("tool_a")] server._tools = [_make_mcp_tool("tool_a")]
_servers[name] = server _servers[name] = server
return [f"mcp_{name}_tool_a"] return [f"mcp__{name}__tool_a"]
with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \ patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \
patch("tools.mcp_tool._MCP_AVAILABLE", True), \ patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_good_server_tool_a"]): patch("tools.mcp_tool._existing_tool_names", return_value=["mcp__good_server__tool_a"]):
_ensure_mcp_loop() _ensure_mcp_loop()
# Capture the logger to verify failed_count in summary # Capture the logger to verify failed_count in summary
@ -3481,12 +3481,12 @@ class TestDiscoveryFailedCount:
server.session = MagicMock() server.session = MagicMock()
server._tools = [_make_mcp_tool("t")] server._tools = [_make_mcp_tool("t")]
_servers[name] = server _servers[name] = server
return [f"mcp_{name}_t"] return [f"mcp__{name}__t"]
with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=selective_register), \ patch("tools.mcp_tool._discover_and_register_server", side_effect=selective_register), \
patch("tools.mcp_tool._MCP_AVAILABLE", True), \ patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_ok1_t", "mcp_ok2_t"]): patch("tools.mcp_tool._existing_tool_names", return_value=["mcp__ok1__t", "mcp__ok2__t"]):
_ensure_mcp_loop() _ensure_mcp_loop()
with patch("tools.mcp_tool.logger") as mock_logger: with patch("tools.mcp_tool.logger") as mock_logger:
@ -3553,7 +3553,7 @@ class TestMCPSelectiveToolLoading:
config, config,
session=SimpleNamespace(), session=SimpleNamespace(),
) )
assert registered == ["mcp_ink_create_service"] assert registered == ["mcp__ink__create_service"]
def test_exclude_filter_registers_all_except_listed_tools(self): def test_exclude_filter_registers_all_except_listed_tools(self):
config = { config = {
@ -3567,8 +3567,8 @@ class TestMCPSelectiveToolLoading:
session=SimpleNamespace(), session=SimpleNamespace(),
) )
assert registered == [ assert registered == [
"mcp_ink_exclude_create_service", "mcp__ink_exclude__create_service",
"mcp_ink_exclude_list_services", "mcp__ink_exclude__list_services",
] ]
def test_include_filter_skips_utility_tools_without_capabilities(self): def test_include_filter_skips_utility_tools_without_capabilities(self):
@ -3582,8 +3582,8 @@ class TestMCPSelectiveToolLoading:
config, config,
session=SimpleNamespace(), session=SimpleNamespace(),
) )
assert registered == ["mcp_ink_no_caps_create_service"] assert registered == ["mcp__ink_no_caps__create_service"]
assert set(mock_registry.get_all_tool_names()) == {"mcp_ink_no_caps_create_service"} assert set(mock_registry.get_all_tool_names()) == {"mcp__ink_no_caps__create_service"}
def test_no_filter_registers_all_server_tools_when_no_utilities_supported(self): def test_no_filter_registers_all_server_tools_when_no_utilities_supported(self):
registered, _ = self._run_discover( registered, _ = self._run_discover(
@ -3593,9 +3593,9 @@ class TestMCPSelectiveToolLoading:
session=SimpleNamespace(), session=SimpleNamespace(),
) )
assert registered == [ assert registered == [
"mcp_ink_no_filter_create_service", "mcp__ink_no_filter__create_service",
"mcp_ink_no_filter_delete_service", "mcp__ink_no_filter__delete_service",
"mcp_ink_no_filter_list_services", "mcp__ink_no_filter__list_services",
] ]
def test_resources_and_prompts_can_be_disabled_explicitly(self): def test_resources_and_prompts_can_be_disabled_explicitly(self):
@ -3618,7 +3618,7 @@ class TestMCPSelectiveToolLoading:
config, config,
session=session, session=session,
) )
assert registered == ["mcp_ink_disabled_utils_create_service"] assert registered == ["mcp__ink_disabled_utils__create_service"]
def test_registers_only_utility_tools_supported_by_server_capabilities(self): def test_registers_only_utility_tools_supported_by_server_capabilities(self):
session = SimpleNamespace( session = SimpleNamespace(
@ -3631,11 +3631,11 @@ class TestMCPSelectiveToolLoading:
{"url": "https://mcp.example.com"}, {"url": "https://mcp.example.com"},
session=session, session=session,
) )
assert "mcp_ink_resources_only_create_service" in registered assert "mcp__ink_resources_only__create_service" in registered
assert "mcp_ink_resources_only_list_resources" in registered assert "mcp__ink_resources_only__list_resources" in registered
assert "mcp_ink_resources_only_read_resource" in registered assert "mcp__ink_resources_only__read_resource" in registered
assert "mcp_ink_resources_only_list_prompts" not in registered assert "mcp__ink_resources_only__list_prompts" not in registered
assert "mcp_ink_resources_only_get_prompt" not in registered assert "mcp__ink_resources_only__get_prompt" not in registered
def test_existing_tool_names_reflect_registered_subset(self): def test_existing_tool_names_reflect_registered_subset(self):
from tools.mcp_tool import _existing_tool_names, _servers, _discover_and_register_server from tools.mcp_tool import _existing_tool_names, _servers, _discover_and_register_server
@ -3664,8 +3664,8 @@ class TestMCPSelectiveToolLoading:
try: try:
registered, existing = asyncio.run(run()) registered, existing = asyncio.run(run())
assert registered == ["mcp_ink_existing_create_service"] assert registered == ["mcp__ink_existing__create_service"]
assert existing == ["mcp_ink_existing_create_service"] assert existing == ["mcp__ink_existing__create_service"]
finally: finally:
_servers.pop("ink_existing", None) _servers.pop("ink_existing", None)
@ -3790,12 +3790,12 @@ class TestMCPBuiltinCollisionGuard:
# Pre-register a "built-in" tool with the name that the MCP tool would produce. # Pre-register a "built-in" tool with the name that the MCP tool would produce.
# Server "abc", tool "search" → mcp_abc_search # Server "abc", tool "search" → mcp_abc_search
builtin_schema = { builtin_schema = {
"name": "mcp_abc_search", "name": "mcp__abc__search",
"description": "A hypothetical built-in", "description": "A hypothetical built-in",
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
} }
mock_registry.register( mock_registry.register(
name="mcp_abc_search", toolset="web", name="mcp__abc__search", toolset="web",
schema=builtin_schema, handler=lambda a, **k: "{}", schema=builtin_schema, handler=lambda a, **k: "{}",
) )
@ -3815,8 +3815,8 @@ class TestMCPBuiltinCollisionGuard:
) )
# The MCP tool should have been skipped — built-in preserved. # The MCP tool should have been skipped — built-in preserved.
assert "mcp_abc_search" not in registered assert "mcp__abc__search" not in registered
assert mock_registry.get_toolset_for_tool("mcp_abc_search") == "web" assert mock_registry.get_toolset_for_tool("mcp__abc__search") == "web"
_servers.pop("abc", None) _servers.pop("abc", None)
@ -3841,8 +3841,8 @@ class TestMCPBuiltinCollisionGuard:
_discover_and_register_server("minimax", {"command": "test", "args": []}) _discover_and_register_server("minimax", {"command": "test", "args": []})
) )
assert "mcp_minimax_web_search" in registered assert "mcp__minimax__web_search" in registered
assert mock_registry.get_toolset_for_tool("mcp_minimax_web_search") == "mcp-minimax" assert mock_registry.get_toolset_for_tool("mcp__minimax__web_search") == "mcp-minimax"
_servers.pop("minimax", None) _servers.pop("minimax", None)
@ -3855,12 +3855,12 @@ class TestMCPBuiltinCollisionGuard:
# Pre-register an MCP tool from a different server. # Pre-register an MCP tool from a different server.
mcp_schema = { mcp_schema = {
"name": "mcp_srv_do_thing", "name": "mcp__srv__do_thing",
"description": "From another MCP server", "description": "From another MCP server",
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
} }
mock_registry.register( mock_registry.register(
name="mcp_srv_do_thing", toolset="mcp-old", name="mcp__srv__do_thing", toolset="mcp-old",
schema=mcp_schema, handler=lambda a, **k: "{}", schema=mcp_schema, handler=lambda a, **k: "{}",
) )
@ -3880,8 +3880,8 @@ class TestMCPBuiltinCollisionGuard:
) )
# MCP-to-MCP collision is allowed — the new server wins. # MCP-to-MCP collision is allowed — the new server wins.
assert "mcp_srv_do_thing" in registered assert "mcp__srv__do_thing" in registered
assert mock_registry.get_toolset_for_tool("mcp_srv_do_thing") == "mcp-srv" assert mock_registry.get_toolset_for_tool("mcp__srv__do_thing") == "mcp-srv"
_servers.pop("srv", None) _servers.pop("srv", None)
@ -3928,7 +3928,7 @@ class TestSanitizeMcpNameComponent:
mcp_tool = _make_mcp_tool(name="search") mcp_tool = _make_mcp_tool(name="search")
schema = _convert_mcp_schema("ai.exa/exa", mcp_tool) schema = _convert_mcp_schema("ai.exa/exa", mcp_tool)
assert schema["name"] == "mcp_ai_exa_exa_search" assert schema["name"] == "mcp__ai_exa_exa__search"
# Must match Anthropic's pattern: ^[a-zA-Z0-9_-]{1,128}$ # Must match Anthropic's pattern: ^[a-zA-Z0-9_-]{1,128}$
import re import re
assert re.match(r"^[a-zA-Z0-9_-]{1,128}$", schema["name"]) assert re.match(r"^[a-zA-Z0-9_-]{1,128}$", schema["name"])
@ -3950,16 +3950,16 @@ class TestSanitizeMcpNameComponent:
reg = ToolRegistry() reg = ToolRegistry()
reg.register( reg.register(
name="mcp_ai_exa_exa_search", name="mcp__ai_exa_exa__search",
toolset="mcp-ai.exa/exa", toolset="mcp-ai.exa/exa",
schema={"name": "mcp_ai_exa_exa_search", "description": "Search", "parameters": {"type": "object", "properties": {}}}, schema={"name": "mcp__ai_exa_exa__search", "description": "Search", "parameters": {"type": "object", "properties": {}}},
handler=lambda *_args, **_kwargs: "{}", handler=lambda *_args, **_kwargs: "{}",
) )
reg.register_toolset_alias("ai.exa/exa", "mcp-ai.exa/exa") reg.register_toolset_alias("ai.exa/exa", "mcp-ai.exa/exa")
with patch("tools.registry.registry", reg): with patch("tools.registry.registry", reg):
assert validate_toolset("ai.exa/exa") is True assert validate_toolset("ai.exa/exa") is True
assert "mcp_ai_exa_exa_search" in resolve_toolset("ai.exa/exa") assert "mcp__ai_exa_exa__search" in resolve_toolset("ai.exa/exa")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -3992,9 +3992,9 @@ class TestRegisterMcpServers:
try: try:
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_existing_tool"]): patch("tools.mcp_tool._existing_tool_names", return_value=["mcp__existing__tool"]):
result = register_mcp_servers({"existing": {"command": "test"}}) result = register_mcp_servers({"existing": {"command": "test"}})
assert result == ["mcp_existing_tool"] assert result == ["mcp__existing__tool"]
finally: finally:
_servers.pop("existing", None) _servers.pop("existing", None)
@ -4016,17 +4016,17 @@ class TestRegisterMcpServers:
async def fake_register(name, cfg): async def fake_register(name, cfg):
server = _make_mock_server(name) server = _make_mock_server(name)
server._registered_tool_names = ["mcp_my_server_tool1"] server._registered_tool_names = ["mcp__my_server__tool1"]
_servers[name] = server _servers[name] = server
return ["mcp_my_server_tool1"] return ["mcp__my_server__tool1"]
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \ patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_my_server_tool1"]): patch("tools.mcp_tool._existing_tool_names", return_value=["mcp__my_server__tool1"]):
_ensure_mcp_loop() _ensure_mcp_loop()
result = register_mcp_servers(fake_config) result = register_mcp_servers(fake_config)
assert "mcp_my_server_tool1" in result assert "mcp__my_server__tool1" in result
_servers.pop("my_server", None) _servers.pop("my_server", None)
def test_logs_summary_on_success(self): def test_logs_summary_on_success(self):
@ -4036,13 +4036,13 @@ class TestRegisterMcpServers:
async def fake_register(name, cfg): async def fake_register(name, cfg):
server = _make_mock_server(name) server = _make_mock_server(name)
server._registered_tool_names = ["mcp_srv_t1", "mcp_srv_t2"] server._registered_tool_names = ["mcp__srv__t1", "mcp__srv__t2"]
_servers[name] = server _servers[name] = server
return ["mcp_srv_t1", "mcp_srv_t2"] return ["mcp__srv__t1", "mcp__srv__t2"]
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \ patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_srv_t1", "mcp_srv_t2"]): patch("tools.mcp_tool._existing_tool_names", return_value=["mcp__srv__t1", "mcp__srv__t2"]):
_ensure_mcp_loop() _ensure_mcp_loop()
with patch("tools.mcp_tool.logger") as mock_logger: with patch("tools.mcp_tool.logger") as mock_logger:
@ -4080,7 +4080,7 @@ class TestMcpParallelToolCalls:
with _lock: with _lock:
_parallel_safe_servers.clear() _parallel_safe_servers.clear()
_mcp_tool_server_names.clear() _mcp_tool_server_names.clear()
assert is_mcp_tool_parallel_safe("mcp_docs_search") is False assert is_mcp_tool_parallel_safe("mcp__docs__search") is False
def test_is_mcp_tool_parallel_safe_with_flag(self): def test_is_mcp_tool_parallel_safe_with_flag(self):
"""MCP tool from a parallel-safe server returns True.""" """MCP tool from a parallel-safe server returns True."""
@ -4090,20 +4090,20 @@ class TestMcpParallelToolCalls:
) )
with _lock: with _lock:
_parallel_safe_servers.add("docs") _parallel_safe_servers.add("docs")
_mcp_tool_server_names["mcp_docs_search"] = "docs" _mcp_tool_server_names["mcp__docs__search"] = "docs"
_mcp_tool_server_names["mcp_docs_read_file"] = "docs" _mcp_tool_server_names["mcp__docs__read_file"] = "docs"
_mcp_tool_server_names["mcp_github_list_repos"] = "github" _mcp_tool_server_names["mcp__github__list_repos"] = "github"
try: try:
assert is_mcp_tool_parallel_safe("mcp_docs_search") is True assert is_mcp_tool_parallel_safe("mcp__docs__search") is True
assert is_mcp_tool_parallel_safe("mcp_docs_read_file") is True assert is_mcp_tool_parallel_safe("mcp__docs__read_file") is True
# Different server should be False # Different server should be False
assert is_mcp_tool_parallel_safe("mcp_github_list_repos") is False assert is_mcp_tool_parallel_safe("mcp__github__list_repos") is False
finally: finally:
with _lock: with _lock:
_parallel_safe_servers.discard("docs") _parallel_safe_servers.discard("docs")
_mcp_tool_server_names.pop("mcp_docs_search", None) _mcp_tool_server_names.pop("mcp__docs__search", None)
_mcp_tool_server_names.pop("mcp_docs_read_file", None) _mcp_tool_server_names.pop("mcp__docs__read_file", None)
_mcp_tool_server_names.pop("mcp_github_list_repos", None) _mcp_tool_server_names.pop("mcp__github__list_repos", None)
def test_is_mcp_tool_parallel_safe_server_with_underscores(self): def test_is_mcp_tool_parallel_safe_server_with_underscores(self):
"""Server names containing underscores are correctly matched.""" """Server names containing underscores are correctly matched."""
@ -4113,13 +4113,13 @@ class TestMcpParallelToolCalls:
) )
with _lock: with _lock:
_parallel_safe_servers.add("my_server") _parallel_safe_servers.add("my_server")
_mcp_tool_server_names["mcp_my_server_query"] = "my_server" _mcp_tool_server_names["mcp__my_server__query"] = "my_server"
try: try:
assert is_mcp_tool_parallel_safe("mcp_my_server_query") is True assert is_mcp_tool_parallel_safe("mcp__my_server__query") is True
finally: finally:
with _lock: with _lock:
_parallel_safe_servers.discard("my_server") _parallel_safe_servers.discard("my_server")
_mcp_tool_server_names.pop("mcp_my_server_query", None) _mcp_tool_server_names.pop("mcp__my_server__query", None)
def test_is_mcp_tool_parallel_safe_uses_exact_registered_server(self): def test_is_mcp_tool_parallel_safe_uses_exact_registered_server(self):
"""Ambiguous MCP names must not match a shorter parallel-safe prefix.""" """Ambiguous MCP names must not match a shorter parallel-safe prefix."""
@ -4129,16 +4129,16 @@ class TestMcpParallelToolCalls:
) )
with _lock: with _lock:
_parallel_safe_servers.add("a") _parallel_safe_servers.add("a")
_mcp_tool_server_names["mcp_a_search"] = "a" _mcp_tool_server_names["mcp__a__search"] = "a"
_mcp_tool_server_names["mcp_a_b_tool"] = "a_b" _mcp_tool_server_names["mcp__a_b__tool"] = "a_b"
try: try:
assert is_mcp_tool_parallel_safe("mcp_a_search") is True assert is_mcp_tool_parallel_safe("mcp__a__search") is True
assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is False assert is_mcp_tool_parallel_safe("mcp__a_b__tool") is False
finally: finally:
with _lock: with _lock:
_parallel_safe_servers.discard("a") _parallel_safe_servers.discard("a")
_mcp_tool_server_names.pop("mcp_a_search", None) _mcp_tool_server_names.pop("mcp__a__search", None)
_mcp_tool_server_names.pop("mcp_a_b_tool", None) _mcp_tool_server_names.pop("mcp__a_b__tool", None)
def test_registered_tool_provenance_prevents_prefix_collision(self): def test_registered_tool_provenance_prevents_prefix_collision(self):
"""Registration records exact server ownership for ambiguous names.""" """Registration records exact server ownership for ambiguous names."""
@ -4154,22 +4154,22 @@ class TestMcpParallelToolCalls:
) )
registered = _register_server_tools("a_b", server, {}) registered = _register_server_tools("a_b", server, {})
try: try:
assert registered == ["mcp_a_b_tool"] assert registered == ["mcp__a_b__tool"]
with _lock: with _lock:
assert _mcp_tool_server_names["mcp_a_b_tool"] == "a_b" assert _mcp_tool_server_names["mcp__a_b__tool"] == "a_b"
_parallel_safe_servers.add("a") _parallel_safe_servers.add("a")
assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is False assert is_mcp_tool_parallel_safe("mcp__a_b__tool") is False
with _lock: with _lock:
_parallel_safe_servers.add("a_b") _parallel_safe_servers.add("a_b")
assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is True assert is_mcp_tool_parallel_safe("mcp__a_b__tool") is True
finally: finally:
for tool_name in registered: for tool_name in registered:
registry.deregister(tool_name) registry.deregister(tool_name)
with _lock: with _lock:
_parallel_safe_servers.discard("a") _parallel_safe_servers.discard("a")
_parallel_safe_servers.discard("a_b") _parallel_safe_servers.discard("a_b")
_mcp_tool_server_names.pop("mcp_a_b_tool", None) _mcp_tool_server_names.pop("mcp__a_b__tool", None)
def test_is_mcp_tool_parallel_safe_no_tool_suffix(self): def test_is_mcp_tool_parallel_safe_no_tool_suffix(self):
"""Tool name that is just 'mcp_{server}' without a tool part returns False.""" """Tool name that is just 'mcp_{server}' without a tool part returns False."""
@ -4180,12 +4180,12 @@ class TestMcpParallelToolCalls:
with _lock: with _lock:
_parallel_safe_servers.add("docs") _parallel_safe_servers.add("docs")
_mcp_tool_server_names.pop("mcp_docs", None) _mcp_tool_server_names.pop("mcp_docs", None)
_mcp_tool_server_names.pop("mcp_docs_", None) _mcp_tool_server_names.pop("mcp__docs__", None)
try: try:
# "mcp_docs" has no tool part after the server name # "mcp_docs" has no tool part after the server name
assert is_mcp_tool_parallel_safe("mcp_docs") is False assert is_mcp_tool_parallel_safe("mcp_docs") is False
# "mcp_docs_" has empty tool part # "mcp_docs_" has empty tool part
assert is_mcp_tool_parallel_safe("mcp_docs_") is False assert is_mcp_tool_parallel_safe("mcp__docs__") is False
finally: finally:
with _lock: with _lock:
_parallel_safe_servers.discard("docs") _parallel_safe_servers.discard("docs")

View File

@ -1681,8 +1681,7 @@ class MCPServerTask:
# notifications. Tools absent from the fresh list are no longer # notifications. Tools absent from the fresh list are no longer
# callable, so remove only those stale registry entries first. # callable, so remove only those stale registry entries first.
stale_tool_names = old_tool_names - { stale_tool_names = old_tool_names - {
f"mcp_{sanitize_mcp_name_component(self.name)}_" mcp_prefixed_tool_name(self.name, tool.name)
f"{sanitize_mcp_name_component(tool.name)}"
for tool in new_mcp_tools for tool in new_mcp_tools
} }
for tool_name in stale_tool_names: for tool_name in stale_tool_names:
@ -3995,6 +3994,27 @@ def sanitize_mcp_name_component(value: str) -> str:
return re.sub(r"[^A-Za-z0-9_]", "_", str(value or "")) return re.sub(r"[^A-Za-z0-9_]", "_", str(value or ""))
# Native MCP tool-name prefix. Hermes uses the ``mcp__<server>__<tool>``
# convention shared by Claude Code, Codex, and OpenCode (anomalyco/opencode
# #33533). The double-underscore delimiter disambiguates the server/tool
# boundary even when either component contains underscores, and matches the
# naming models are trained on. It also aligns native registration with the
# Anthropic-OAuth wire form (``_MCP_TOOL_PREFIX`` in anthropic_adapter.py),
# removing the single->double rewrite that path previously had to perform.
MCP_TOOL_NAME_PREFIX = "mcp__"
_MCP_NAME_DELIM = "__"
def mcp_prefixed_tool_name(server_name: str, tool_name: str) -> str:
"""Build the registry/wire name for an MCP tool.
Produces ``mcp__<sanitizedServer>__<sanitizedTool>``.
"""
safe_server = sanitize_mcp_name_component(server_name)
safe_tool = sanitize_mcp_name_component(tool_name)
return f"{MCP_TOOL_NAME_PREFIX}{safe_server}{_MCP_NAME_DELIM}{safe_tool}"
def _convert_mcp_schema(server_name: str, mcp_tool) -> dict: def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
"""Convert an MCP tool listing to the Hermes registry schema format. """Convert an MCP tool listing to the Hermes registry schema format.
@ -4006,9 +4026,7 @@ def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
Returns: Returns:
A dict suitable for ``registry.register(schema=...)``. A dict suitable for ``registry.register(schema=...)``.
""" """
safe_tool_name = sanitize_mcp_name_component(mcp_tool.name) prefixed_name = mcp_prefixed_tool_name(server_name, mcp_tool.name)
safe_server_name = sanitize_mcp_name_component(server_name)
prefixed_name = f"mcp_{safe_server_name}_{safe_tool_name}"
return { return {
"name": prefixed_name, "name": prefixed_name,
"description": mcp_tool.description or f"MCP tool {mcp_tool.name} from {server_name}", "description": mcp_tool.description or f"MCP tool {mcp_tool.name} from {server_name}",
@ -4022,11 +4040,10 @@ def _build_utility_schemas(server_name: str) -> List[dict]:
Returns a list of (schema, handler_factory_name) tuples encoded as dicts Returns a list of (schema, handler_factory_name) tuples encoded as dicts
with keys: schema, handler_key. with keys: schema, handler_key.
""" """
safe_name = sanitize_mcp_name_component(server_name)
return [ return [
{ {
"schema": { "schema": {
"name": f"mcp_{safe_name}_list_resources", "name": mcp_prefixed_tool_name(server_name, "list_resources"),
"description": f"List available resources from MCP server '{server_name}'", "description": f"List available resources from MCP server '{server_name}'",
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -4037,7 +4054,7 @@ def _build_utility_schemas(server_name: str) -> List[dict]:
}, },
{ {
"schema": { "schema": {
"name": f"mcp_{safe_name}_read_resource", "name": mcp_prefixed_tool_name(server_name, "read_resource"),
"description": f"Read a resource by URI from MCP server '{server_name}'", "description": f"Read a resource by URI from MCP server '{server_name}'",
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -4054,7 +4071,7 @@ def _build_utility_schemas(server_name: str) -> List[dict]:
}, },
{ {
"schema": { "schema": {
"name": f"mcp_{safe_name}_list_prompts", "name": mcp_prefixed_tool_name(server_name, "list_prompts"),
"description": f"List available prompts from MCP server '{server_name}'", "description": f"List available prompts from MCP server '{server_name}'",
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -4065,7 +4082,7 @@ def _build_utility_schemas(server_name: str) -> List[dict]:
}, },
{ {
"schema": { "schema": {
"name": f"mcp_{safe_name}_get_prompt", "name": mcp_prefixed_tool_name(server_name, "get_prompt"),
"description": f"Get a prompt by name from MCP server '{server_name}'", "description": f"Get a prompt by name from MCP server '{server_name}'",
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -4525,15 +4542,15 @@ def discover_mcp_tools() -> List[str]:
def is_mcp_tool_parallel_safe(tool_name: str) -> bool: def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
"""Check if an MCP tool belongs to a server that supports parallel tool calls. """Check if an MCP tool belongs to a server that supports parallel tool calls.
MCP tool names follow the pattern ``mcp_{server}_{tool}``, but that string MCP tool names follow the pattern ``mcp__{server}__{tool}``, but that
shape is ambiguous when server names contain underscores. Use the exact string shape is ambiguous when server names contain underscores. Use the
server provenance captured at registration time rather than prefix exact server provenance captured at registration time rather than prefix
matching, then check whether that server's config includes matching, then check whether that server's config includes
``supports_parallel_tool_calls: true``. ``supports_parallel_tool_calls: true``.
Returns False for non-MCP tools or tools from servers without the flag. Returns False for non-MCP tools or tools from servers without the flag.
""" """
if not tool_name.startswith("mcp_"): if not tool_name.startswith(MCP_TOOL_NAME_PREFIX):
return False return False
with _lock: with _lock:
server_name = _mcp_tool_server_names.get(tool_name) server_name = _mcp_tool_server_names.get(tool_name)