Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1713,9 +1713,9 @@ def __init__(
# Parse and validate toolsets
self.enabled_toolsets = toolsets
if toolsets and "all" not in toolsets and "*" not in toolsets:
# Validate each toolset — MCP server names are added by
# _get_platform_tools() but aren't registered in TOOLSETS yet
# (that happens later in _sync_mcp_toolsets), so exclude them.
# Validate each toolset — MCP server names are resolved via
# live registry aliases (registered during discover_mcp_tools),
# but discovery hasn't run yet at this point, so exclude them.
mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys())
invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names]
if invalid:
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"vincentcharlebois@gmail.com": "vincentcharlebois",
"aryan@synvoid.com": "aryansingh",
"johnsonblake1@gmail.com": "blakejohnson",
"greer.guthrie@gmail.com": "g-guthrie",
"kennyx102@gmail.com": "bobashopcashier",
"shokatalishaikh95@gmail.com": "areu01or00",
"bryan@intertwinesys.com": "bryanyoung",
Expand Down
33 changes: 33 additions & 0 deletions tests/test_toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ def test_all_alias_valid(self):
def test_invalid(self):
assert validate_toolset("nonexistent") is False

def test_mcp_alias_uses_live_registry(self, monkeypatch):
reg = ToolRegistry()
reg.register(
name="mcp_dynserver_ping",
toolset="mcp-dynserver",
schema=_make_schema("mcp_dynserver_ping", "Ping"),
handler=_dummy_handler,
)
reg.register_toolset_alias("dynserver", "mcp-dynserver")

monkeypatch.setattr("tools.registry.registry", reg)

assert validate_toolset("dynserver") is True
assert validate_toolset("mcp-dynserver") is True
assert "mcp_dynserver_ping" in resolve_toolset("dynserver")


class TestGetToolsetInfo:
def test_leaf(self):
Expand Down Expand Up @@ -150,6 +166,23 @@ def test_runtime_creation(self):
del TOOLSETS["_test_custom"]


class TestRegistryOwnedToolsets:
def test_registry_membership_is_live(self, monkeypatch):
reg = ToolRegistry()
reg.register(
name="test_live_toolset_tool",
toolset="test-live-toolset",
schema=_make_schema("test_live_toolset_tool", "Live"),
handler=_dummy_handler,
)

monkeypatch.setattr("tools.registry.registry", reg)

assert validate_toolset("test-live-toolset") is True
assert get_toolset("test-live-toolset")["tools"] == ["test_live_toolset_tool"]
assert resolve_toolset("test-live-toolset") == ["test_live_toolset_tool"]


class TestToolsetConsistency:
"""Verify structural integrity of the built-in TOOLSETS dict."""

Expand Down
80 changes: 35 additions & 45 deletions tests/tools/test_mcp_dynamic_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,19 @@ class TestRegisterServerTools:
def mock_registry(self):
return ToolRegistry()

@pytest.fixture
def mock_toolsets(self):
return {
"hermes-cli": {"tools": ["terminal"], "description": "CLI", "includes": []},
"hermes-telegram": {"tools": ["terminal"], "description": "TG", "includes": []},
"custom-toolset": {"tools": [], "description": "Other", "includes": []},
}

def test_injects_hermes_toolsets(self, mock_registry, mock_toolsets):
"""Tools are injected into hermes-* toolsets but not custom ones."""
def test_exposes_live_server_aliases(self, mock_registry):
"""Registered MCP tools are reachable via live raw-server aliases."""
server = MCPServerTask("my_srv")
server._tools = [_make_mcp_tool("my_tool", "desc")]
server.session = MagicMock()
from toolsets import resolve_toolset, validate_toolset

with patch("tools.registry.registry", mock_registry), \
patch("toolsets.create_custom_toolset"), \
patch.dict("toolsets.TOOLSETS", mock_toolsets, clear=True):

with patch("tools.registry.registry", mock_registry):
registered = _register_server_tools("my_srv", server, {})

assert "mcp_my_srv_my_tool" in registered
assert "mcp_my_srv_my_tool" in mock_registry.get_all_tool_names()

# Injected into hermes-* toolsets
assert "mcp_my_srv_my_tool" in mock_toolsets["hermes-cli"]["tools"]
assert "mcp_my_srv_my_tool" in mock_toolsets["hermes-telegram"]["tools"]
# NOT into non-hermes toolsets
assert "mcp_my_srv_my_tool" not in mock_toolsets["custom-toolset"]["tools"]
assert "mcp_my_srv_my_tool" in registered
assert "mcp_my_srv_my_tool" in mock_registry.get_all_tool_names()
assert validate_toolset("my_srv") is True
assert "mcp_my_srv_my_tool" in resolve_toolset("my_srv")


class TestRefreshTools:
Expand All @@ -58,19 +43,13 @@ class TestRefreshTools:
def mock_registry(self):
return ToolRegistry()

@pytest.fixture
def mock_toolsets(self):
return {
"hermes-cli": {"tools": ["terminal"], "description": "CLI", "includes": []},
"hermes-telegram": {"tools": ["terminal"], "description": "TG", "includes": []},
}

@pytest.mark.asyncio
async def test_nuke_and_repave(self, mock_registry, mock_toolsets):
async def test_nuke_and_repave(self, mock_registry):
"""Old tools are removed and new tools registered on refresh."""
server = MCPServerTask("live_srv")
server._refresh_lock = asyncio.Lock()
server._config = {}
from toolsets import resolve_toolset

# Seed initial state: one old tool registered
mock_registry.register(
Expand All @@ -79,7 +58,6 @@ async def test_nuke_and_repave(self, mock_registry, mock_toolsets):
description="", emoji="",
)
server._registered_tool_names = ["mcp_live_srv_old_tool"]
mock_toolsets["hermes-cli"]["tools"].append("mcp_live_srv_old_tool")

# New tool list from server
new_tool = _make_mcp_tool("new_tool", "new behavior")
Expand All @@ -89,20 +67,13 @@ async def test_nuke_and_repave(self, mock_registry, mock_toolsets):
)
)

with patch("tools.registry.registry", mock_registry), \
patch("toolsets.create_custom_toolset"), \
patch.dict("toolsets.TOOLSETS", mock_toolsets, clear=True):

with patch("tools.registry.registry", mock_registry):
await server._refresh_tools()

# Old tool completely gone
assert "mcp_live_srv_old_tool" not in mock_registry.get_all_tool_names()
assert "mcp_live_srv_old_tool" not in mock_toolsets["hermes-cli"]["tools"]

# New tool registered
assert "mcp_live_srv_new_tool" in mock_registry.get_all_tool_names()
assert "mcp_live_srv_new_tool" in mock_toolsets["hermes-cli"]["tools"]
assert server._registered_tool_names == ["mcp_live_srv_new_tool"]
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_new_tool" in mock_registry.get_all_tool_names()
assert "mcp_live_srv_new_tool" in resolve_toolset("live_srv")
assert server._registered_tool_names == ["mcp_live_srv_new_tool"]


class TestMessageHandler:
Expand Down Expand Up @@ -165,6 +136,25 @@ def test_preserves_toolset_check_if_other_tools_remain(self):
# bar still in ts1, so check should remain
assert "ts1" in reg._toolset_checks

def test_removes_toolset_alias_when_last_tool_is_removed(self):
reg = ToolRegistry()
reg.register(name="foo", toolset="mcp-srv", schema={}, handler=lambda x: x)
reg.register_toolset_alias("srv", "mcp-srv")

reg.deregister("foo")

assert reg.get_toolset_alias_target("srv") is None

def test_preserves_toolset_alias_while_toolset_still_exists(self):
reg = ToolRegistry()
reg.register(name="foo", toolset="mcp-srv", schema={}, handler=lambda x: x)
reg.register(name="bar", toolset="mcp-srv", schema={}, handler=lambda x: x)
reg.register_toolset_alias("srv", "mcp-srv")

reg.deregister("foo")

assert reg.get_toolset_alias_target("srv") == "mcp-srv"

def test_noop_for_unknown_tool(self):
reg = ToolRegistry()
reg.deregister("nonexistent") # Should not raise
Loading
Loading