hermes-agent/tests/tools/test_refresh_agent_mcp_tools.py
alt-glitch b6e2a54a94 fix(mcp): address adversarial review round 1 (cache parity, gates, races)
Consolidated findings from three independent reviewers (Codex, Claude Code, a
Hermes subagent w/ the hermes-agent-dev skill):

- BLOCKING: refresh_agent_mcp_tools rebuilt only the registry subset, silently
  dropping post-build-injected memory-provider (mem0/honcho/…) and context-
  engine (lcm_*) tools on every refresh. Now additive-preserving: re-applies
  the same injectors agent_init uses, staged on locals and published atomically.
- Re-injection now honors the #5544 enabled_toolsets gate for context-engine
  tools, so a restricted-toolset platform can't get lcm_* leaked back in.
- Atomic read-diff-publish under one lock: the returned `added` set and the
  (tools, valid_tool_names) pair are consistent even under concurrent callers
  (no half-swap, no TOCTOU).
- background_review fork opts out (_skip_mcp_refresh) so its byte-identical
  tools[] cache parity with the parent is preserved.
- CLI /reload-mcp routed through the shared helper (was a 4th divergent copy
  with the same clobber bug + missing disabled_toolsets).
- Explicit reloads (TUI RPC + CLI) pass enabled_override so a server the user
  just enabled in config this session is picked up; automatic paths reuse the
  agent's build-time selection.
- mcp_discovery_timeout default 5.0 -> 1.5s: correctness now comes from the
  between-turns refresh, so the startup wait is only a small turn-1 UX bump
  rather than a heavy dead-server latency penalty.
- has_registered_mcp_tools checks registered TOOLS (not connected servers) so a
  zero-tool/prompt-only server doesn't make the per-turn hook fire forever.
- Tests: rewrote the thread-safety test to actually exercise the write path
  (alternating tool sets), added the #5544-gate regression, the memory/context
  preservation regression, and a "callable next turn via valid_tool_names"
  contract; removed a dead monkeypatch line.
2026-06-19 11:57:43 -07:00

275 lines
10 KiB
Python

"""Tests for the shared MCP agent-tool refresh helper and discovery-wait bound.
``refresh_agent_mcp_tools`` is the single rebuild path used by the TUI
``reload.mcp`` RPC, the gateway reload, and the late-binding refresh thread —
so a slow MCP server that connects after the agent's one-time tool snapshot is
picked up everywhere identically. These assert the *contracts* those callers
rely on (name-based diff, in-place mutation, agent-scoped filtering) rather than
freezing any particular tool list.
"""
import threading
import types
from tools import mcp_tool
def _tool(name):
return {"type": "function", "function": {"name": name, "description": "", "parameters": {}}}
def _agent(tool_names, *, enabled=None, disabled=None):
a = types.SimpleNamespace()
a.tools = [_tool(n) for n in tool_names]
a.valid_tool_names = set(tool_names)
a.enabled_toolsets = enabled
a.disabled_toolsets = disabled
return a
def test_refresh_adds_late_landing_tools(monkeypatch):
"""A server that registers after build → its tools land in the snapshot."""
agent = _agent(["read_file", "terminal"])
new_defs = [_tool(n) for n in ("read_file", "terminal", "mcp_granola_get_account_info")]
monkeypatch.setattr(mcp_tool, "get_tool_definitions", lambda **kw: new_defs, raising=False)
# get_tool_definitions is imported inside the helper from model_tools, so patch there too.
import model_tools
monkeypatch.setattr(model_tools, "get_tool_definitions", lambda **kw: new_defs)
added = mcp_tool.refresh_agent_mcp_tools(agent)
assert added == {"mcp_granola_get_account_info"}
assert "mcp_granola_get_account_info" in agent.valid_tool_names
assert len(agent.tools) == 3
def test_refresh_no_change_returns_empty_and_leaves_agent_untouched(monkeypatch):
"""No new tools → empty set, and the snapshot object is not swapped."""
agent = _agent(["read_file", "terminal"])
original_tools = agent.tools
import model_tools
monkeypatch.setattr(
model_tools, "get_tool_definitions",
lambda **kw: [_tool("read_file"), _tool("terminal")],
)
added = mcp_tool.refresh_agent_mcp_tools(agent)
assert added == set()
assert agent.tools is original_tools # not replaced → no churn / no cache thrash
def test_refresh_detects_equal_size_swap(monkeypatch):
"""Name-based diff catches an add+remove of equal count (count-compare can't)."""
agent = _agent(["a", "old_mcp_tool"]) # 2 tools
import model_tools
# Same COUNT (2) but a different membership: old_mcp_tool removed, new added.
monkeypatch.setattr(
model_tools, "get_tool_definitions",
lambda **kw: [_tool("a"), _tool("new_mcp_tool")],
)
added = mcp_tool.refresh_agent_mcp_tools(agent)
assert added == {"new_mcp_tool"}
assert agent.valid_tool_names == {"a", "new_mcp_tool"}
assert "old_mcp_tool" not in agent.valid_tool_names
def test_refresh_passes_agent_toolset_filters(monkeypatch):
"""The rebuild re-derives with the agent's OWN enabled/disabled toolsets."""
agent = _agent(["a"], enabled=["coding", "granola"], disabled=["messaging"])
seen = {}
import model_tools
def _capture(**kw):
seen.update(kw)
return [_tool("a"), _tool("b")]
monkeypatch.setattr(model_tools, "get_tool_definitions", _capture)
mcp_tool.refresh_agent_mcp_tools(agent)
assert seen["enabled_toolsets"] == ["coding", "granola"]
assert seen["disabled_toolsets"] == ["messaging"]
def test_refresh_preserves_memory_provider_and_context_engine_tools(monkeypatch):
"""B1 regression: a rebuild must NOT drop post-build-injected tools.
get_tool_definitions() returns only the registry-derived tools. agent_init
appends memory-provider tools (mem0/honcho/…) and context-engine tools
(lcm_*) directly onto agent.tools AFTER that. A naive
`agent.tools = get_tool_definitions()` would silently delete them on every
refresh. The helper must re-inject them.
"""
# Agent already carries: a built-in, a memory-provider tool, a context tool.
agent = _agent(["read_file", "memory_search", "lcm_grep"])
# Provider exposes its schemas; context compressor exposes lcm_*.
agent._memory_manager = types.SimpleNamespace(
get_all_tool_schemas=lambda: [
{"name": "memory_search", "description": "", "parameters": {}}
]
)
agent.context_compressor = types.SimpleNamespace(
get_tool_schemas=lambda: [
{"name": "lcm_grep", "description": "", "parameters": {}}
]
)
agent._context_engine_tool_names = {"lcm_grep"}
import model_tools
# The registry now ALSO has a newly-connected MCP tool, but does NOT contain
# the memory/context tools (they're never in get_tool_definitions output).
monkeypatch.setattr(
model_tools, "get_tool_definitions",
lambda **kw: [_tool("read_file"), _tool("mcp_new_server_tool")],
)
added = mcp_tool.refresh_agent_mcp_tools(agent)
# The new MCP tool landed AND the injected families survived.
assert "mcp_new_server_tool" in agent.valid_tool_names
assert "memory_search" in agent.valid_tool_names # not clobbered
assert "lcm_grep" in agent.valid_tool_names # not clobbered
assert added == {"mcp_new_server_tool"}
def test_refresh_respects_context_engine_toolset_gate(monkeypatch):
"""#5544: context-engine tools must NOT be re-injected on a restricted
toolset. A platform with enabled_toolsets that excludes context_engine
must not get lcm_* leaked back in by a refresh."""
agent = _agent(["read_file"], enabled=["coding"]) # context_engine NOT enabled
agent.context_compressor = types.SimpleNamespace(
get_tool_schemas=lambda: [{"name": "lcm_grep", "description": "", "parameters": {}}]
)
agent._context_engine_tool_names = set()
import model_tools
monkeypatch.setattr(
model_tools, "get_tool_definitions",
lambda **kw: [_tool("read_file"), _tool("mcp_new_tool")],
)
mcp_tool.refresh_agent_mcp_tools(agent)
assert "mcp_new_tool" in agent.valid_tool_names # MCP tool still lands
assert "lcm_grep" not in agent.valid_tool_names # gated out (#5544)
def test_refreshed_tool_is_callable_through_valid_tool_names_guard(monkeypatch):
"""The whole point: a late tool, once refreshed, passes the name guard the
run loop uses to accept/reject tool calls (agent.valid_tool_names)."""
agent = _agent(["read_file"])
import model_tools
monkeypatch.setattr(
model_tools, "get_tool_definitions",
lambda **kw: [_tool("read_file"), _tool("mcp_granola_list_meetings")],
)
# Before refresh the run loop would reject the call ("Tool does not exist").
assert "mcp_granola_list_meetings" not in agent.valid_tool_names
mcp_tool.refresh_agent_mcp_tools(agent)
# After refresh the same guard accepts it AND it's in the tools= payload.
assert "mcp_granola_list_meetings" in agent.valid_tool_names
assert any(t["function"]["name"] == "mcp_granola_list_meetings" for t in agent.tools)
def test_refresh_is_thread_safe_under_concurrent_calls(monkeypatch):
"""Concurrent refreshes keep tools / valid_tool_names coherent.
The registry alternates between two DIFFERENT tool sets every call, so the
write path (publish) runs repeatedly rather than short-circuiting on the
no-change early return — this actually exercises the lock. The invariant:
a reader of ``valid_tool_names`` must always match ``agent.tools``, and the
final published pair must be one of the two valid sets (never a mix).
"""
agent = _agent(["a"])
import itertools
set_a = [_tool("a"), _tool("b")]
set_b = [_tool("a"), _tool("c")]
flip = itertools.cycle([set_a, set_b])
flip_lock = threading.Lock()
def _gtd(**kw):
with flip_lock:
return list(next(flip))
import model_tools
monkeypatch.setattr(model_tools, "get_tool_definitions", _gtd)
errors = []
def _worker():
try:
for _ in range(50):
mcp_tool.refresh_agent_mcp_tools(agent)
# Coherence invariant: the name set must match the tool list
# at every observation, never a torn cross-attribute state.
names = {t["function"]["name"] for t in agent.tools}
assert agent.valid_tool_names == names
assert names in ({"a", "b"}, {"a", "c"})
except Exception as exc: # pragma: no cover - failure path
errors.append(exc)
threads = [threading.Thread(target=_worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors
assert agent.valid_tool_names in ({"a", "b"}, {"a", "c"})
# ── discovery-wait bound (mcp_discovery_timeout config) ──────────────────────
def test_resolve_discovery_timeout_explicit_wins(monkeypatch):
from hermes_cli import mcp_startup
assert mcp_startup._resolve_discovery_timeout(2.5) == 2.5
def test_resolve_discovery_timeout_reads_config(monkeypatch):
from hermes_cli import mcp_startup
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "load_config", lambda: {"mcp_discovery_timeout": 8.0})
assert mcp_startup._resolve_discovery_timeout(None) == 8.0
def test_resolve_discovery_timeout_falls_back_on_bad_value(monkeypatch):
from hermes_cli import mcp_startup
import hermes_cli.config as cfg
# Non-positive / unparsable → historical safe default, never hang.
monkeypatch.setattr(cfg, "load_config", lambda: {"mcp_discovery_timeout": 0})
assert mcp_startup._resolve_discovery_timeout(None) == 0.75
monkeypatch.setattr(cfg, "load_config", lambda: {"mcp_discovery_timeout": "oops"})
assert mcp_startup._resolve_discovery_timeout(None) == 0.75
def test_wait_returns_instantly_when_no_discovery_thread(monkeypatch):
"""The common case (no MCP / discovery done) pays ~0s regardless of bound."""
import time
from hermes_cli import mcp_startup
monkeypatch.setattr(mcp_startup, "_mcp_discovery_thread", None)
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "load_config", lambda: {"mcp_discovery_timeout": 999.0})
t0 = time.time()
mcp_startup.wait_for_mcp_discovery()
assert time.time() - t0 < 0.2 # never blocks on the bound when nothing's pending