diff --git a/tests/tools/test_mcp_multiplex_connection_keys.py b/tests/tools/test_mcp_multiplex_connection_keys.py index 869f59d506..c535eb5401 100644 --- a/tests/tools/test_mcp_multiplex_connection_keys.py +++ b/tests/tools/test_mcp_multiplex_connection_keys.py @@ -241,3 +241,34 @@ def test_parallel_safe_opt_in_is_per_profile(two_profiles): two_profiles("a") assert disc.is_mcp_tool_parallel_safe("mcp__x__t") is False + + +def test_served_profile_without_multiplex_flag_gets_its_own_connection(two_profiles, monkeypatch): + """A dashboard/desktop backend serves profiles through the HERMES_HOME override with + ``gateway.multiplex_profiles`` off; a same-named server with other credentials must still be a + separate connection there, or profile B calls the server as profile A (#111151). The launch + profile itself (no override) keeps the bare, unscoped key.""" + import tools.mcp_tool as core + from tools import mcp_tool_discovery as disc + from tools import mcp_tool_registration as reg + from tools.mcp_tool_scope import _resolve_server_key, _server_key + from tools.registry import registry + + monkeypatch.setattr("agent.secret_scope.is_multiplex_active", lambda: False) + cfg_a = {"url": "https://mcp.example/x", "headers": {"Authorization": "Bearer A"}} + cfg_b = {"url": "https://mcp.example/x", "headers": {"Authorization": "Bearer B"}} + + scope_a = two_profiles("a") + srv_a = _server("x", cfg_a) + disc._adopt_server("x", srv_a) + srv_a._registered_tool_names = reg._register_server_tools("x", srv_a, cfg_a) + assert (scope_a, "x") in core._servers + + two_profiles("b") + assert _resolve_server_key("x") != (scope_a, "x") + assert registry.get_tool_names_for_toolset("mcp-x") == [] + assert "x" in disc._select_new_servers({"x": cfg_b}) + + with patch("hermes_constants.get_hermes_home_override", return_value=None): + assert core._mcp_registry_scope() is None + assert _server_key("x") == "x" diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 0703f8b496..326b1f4b36 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -637,10 +637,18 @@ def _update_death_supervisor(verb: str, pgids) -> None: def _mcp_registry_scope() -> Optional[str]: - """Registry scope for MCP registrations: a profile overlay under a multiplexer, else None.""" + """Registry scope for MCP registrations: a profile overlay when this process serves profiles, + else None. Under ``gateway.multiplex_profiles`` every turn runs scoped; a process that serves a + routed profile through the HERMES_HOME override (dashboard/desktop backend, per-profile cron + ticker) is a multiplexer too, even with the flag off — keying its connections by the bare name + would hand one profile's credentialed connection to every other served profile (#111151). + Single-profile processes (no override, or an override naming their own home) keep bare names.""" from agent.secret_scope import is_multiplex_active + from hermes_constants import get_hermes_home_override, get_process_hermes_home, hermes_home_key if not is_multiplex_active(): - return None + override = get_hermes_home_override() + if override is None or hermes_home_key(override) == hermes_home_key(get_process_hermes_home()): + return None from tools.registry import registry return registry.current_scope_key() diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index de0d7247a8..24d592b6bf 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -436,6 +436,7 @@ profile and never shares with the default or any sibling: | Session-search knobs (`sessions.cjk_fts`, `sessions.search_slow_ms`) | The profile's `config.yaml` | Documented default — never the default profile's bridged value | | Platform proxies (`TELEGRAM_PROXY`, `DISCORD_PROXY`, `HTTPS_PROXY`, …) | The profile's own `.env` | Direct connection — never the default profile's proxy | | MCP discovery in the Desktop/dashboard backend | Once per served profile home | A profile selected after another has already built an agent still discovers its own `mcp_servers` | +| MCP connections in the Desktop/dashboard backend and the per-profile cron ticker | Keyed per served profile even with `gateway.multiplex_profiles` off — same rule as the multiplexer | A same-named `mcp_servers` entry with other credentials is its own connection; a served profile never calls a server as another profile | | Dashboard actions (`hermes -p …` spawned by the Desktop/dashboard) | A scrubbed child env pinned to that profile's `HERMES_HOME` | The child loads its own `.env`; the dashboard profile's tokens and ports are not inherited | | Cron `.env` tuning (`HERMES_CRON_TIMEOUT`, `HERMES_MODEL` fallback, `HERMES_CRON_MAX_PARALLEL`, prefill file), worker / Bot Chat child env | The profile's own `.env`; children never inherit the default profile's `.env` settings or bridged `TERMINAL_*` policy | Cron defaults / model refusal, exactly as a standalone `hermes -p gateway run` | | Kanban workers and notifications for a profile's tasks | The assignee's `.env` + `config.yaml` (toolset pin, terminal backend, media policy, display language) | — |