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 conventionfix/verification-admin-route-recovery
parent
5986cdd380
commit
e01f58ff1f
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue