Files
hermes-agent/tests/gateway/test_multiplex_mcp_discovery.py
T
Teknium ee0e234a2c fix(gateway): discover and reload MCP servers per profile under multiplex
A multiplexed gateway ran `discover_mcp_tools()` once, unscoped, at boot
and again on `/reload-mcp`, so only the launch profile's `mcp_servers`
ever connected; secondary profiles' servers never registered, and a
`/reload-mcp` from any profile tore down every profile's connections.

- `_discover_gateway_mcp_tools()`: under multiplex, run discovery once per
  served profile inside `_profile_runtime_scope`, carried into the
  executor via `copy_context()` (same shape as
  `_run_in_executor_with_context`). Single-profile path unchanged.
- `_execute_mcp_reload()`: enter the requesting profile's scope when the
  caller (e.g. button-confirm callback) did not; shut down / rediscover /
  report only that profile's servers; refresh only that profile's cached
  agents.
- `shutdown_mcp_servers(scope=)`: scoped teardown keyed by the new
  `_server_scope_keys` ownership map; leaves the shared MCP loop running
  while other profiles' servers are live. Unscoped call keeps the full
  historical behavior.
- MCP tools register into the owning profile's registry overlay
  (`registry.register(scope=...)`), and `registry.deregister()` gains a
  matching `scope=` kwarg. Plugin callers still cannot name another
  profile's scope; the plugin-vs-global guard is unchanged for them.

Fixes #95518

Co-authored-by: fangliquanflq <fangliquan@qq.com>
Co-authored-by: Kong <mgongzai@gmail.com>
Co-authored-by: roraag <232666910+roraag@users.noreply.github.com>
2026-09-02 07:00:13 -07:00

122 lines
4.3 KiB
Python

"""Multiplexed gateways discover and reload MCP servers per profile (#95518)."""
from __future__ import annotations
import threading
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from gateway.config import GatewayConfig, Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
from hermes_constants import get_hermes_home, hermes_home_key
@pytest.mark.asyncio
async def test_gateway_boot_discovers_mcp_under_every_profile_home(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import gateway.run as gateway_run
from tools import mcp_tool
homes = [("default", tmp_path / "default"), ("worker", tmp_path / "worker")]
for _name, home in homes:
home.mkdir()
seen: list[tuple[Path, str]] = []
def fake_discover() -> list[str]:
seen.append((get_hermes_home(), threading.current_thread().name))
return []
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex, profile_allowlist=None: homes,
)
monkeypatch.setattr(mcp_tool, "discover_mcp_tools", fake_discover)
await gateway_run._discover_gateway_mcp_tools(GatewayConfig(multiplex_profiles=True))
# Ran once per profile, under that profile's home, off the loop thread.
assert [home for home, _ in seen] == [home for _, home in homes]
assert all(thread != threading.current_thread().name for _, thread in seen)
@pytest.mark.asyncio
async def test_reload_mcp_only_touches_requesting_profile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from gateway.run import GatewayRunner
from tools import mcp_tool
worker_home = tmp_path / "profiles" / "worker"
worker_home.mkdir(parents=True)
worker_scope = hermes_home_key(worker_home)
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._resolve_profile_home_for_source = MagicMock(return_value=worker_home)
runner._agent_cache = {}
runner._agent_cache_lock = None
runner._async_session_store = SimpleNamespace(
get_or_create_session=MagicMock(side_effect=RuntimeError("skip transcript")),
)
monkeypatch.setattr(mcp_tool, "_servers", {"default-srv": object(), "worker-srv": object()})
monkeypatch.setattr(
mcp_tool, "_server_scope_keys",
{"default-srv": hermes_home_key(tmp_path), "worker-srv": worker_scope},
)
seen: list[tuple] = []
def fake_shutdown(*, scope=None) -> None:
seen.append(("shutdown", scope, get_hermes_home()))
def fake_discover() -> list[str]:
seen.append(("discover", get_hermes_home()))
return []
monkeypatch.setattr(mcp_tool, "shutdown_mcp_servers", fake_shutdown)
monkeypatch.setattr(mcp_tool, "discover_mcp_tools", fake_discover)
event = MessageEvent(
text="/reload-mcp", message_id="m1",
source=SessionSource(
platform=Platform.TELEGRAM, user_id="u1", chat_id="c1",
chat_type="dm", profile="worker",
),
)
result = await runner._execute_mcp_reload(event)
# Entered worker's scope itself, shut down only worker's servers, and
# reported only worker's servers (default's untouched connection is not
# "removed").
assert seen == [
("shutdown", worker_scope, worker_home),
("discover", worker_home),
]
assert "default-srv" not in result
def test_deregister_scope_kwarg_targets_overlay_and_keeps_plugin_confinement() -> None:
from tools.registry import ToolRegistry
reg = ToolRegistry()
reg.register("mcp__s__t", "mcp-s", {"name": "mcp__s__t", "description": "d"},
lambda **kw: None, scope="/home/p1")
assert reg.snapshot_registration("mcp__s__t", scope="/home/p1") is not None
reg.deregister("mcp__s__t") # unscoped: global slot only, overlay untouched
assert reg.snapshot_registration("mcp__s__t", scope="/home/p1") is not None
reg.deregister("mcp__s__t", scope="/home/p1")
assert reg.snapshot_registration("mcp__s__t", scope="/home/p1") is None
# A plugin module may not name another profile's overlay.
reg._plugin_module_scopes["hermes_plugins.p"] = {"/home/p1"}
reg._caller_module = staticmethod(lambda: "hermes_plugins.p")
with pytest.raises(PermissionError):
reg.deregister("anything", scope="/home/p2")