ee0e234a2c
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>
122 lines
4.3 KiB
Python
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")
|