fix(mcp): dashboard-served profiles keep their own MCP connections and credentials (#111151) - #111481
Conversation
…multiplex flag A dashboard/desktop backend (and the per-profile cron ticker) serves sessions of several profiles through the HERMES_HOME contextvar override while gateway.multiplex_profiles stays off. _mcp_registry_scope() keyed every MCP connection by the bare server name in that mode, so the first profile to discover `zernio` owned the only connection and every later served profile — including one whose config carries a different Authorization header — called the server through it and got the other account's data back (#111151). The registry scope now follows the served home: a routed profile (an override naming a home other than the process home) gets the same per-profile overlay the multiplexer uses, so a same-named server with other credentials is a separate connection, discovery for profile B is a connect candidate instead of "already connected", and status/tool views stay per profile. Single-profile processes (no override) keep bare keys, byte-identical to before. Fixes #111151 Credit: #111158 by @KoNit-K located the inert flag on hermes_cli surfaces; its fix (activating fail-closed multiplex secret scoping from config.yaml on the dashboard) is not taken — the connection-key seam, not the secret-scope mode, is what leaks the connection, and flipping the process-wide mode from the dashboard would change credential resolution for every code path in it.
૮ >ﻌ< ა ci reviewran on c36dd99 — fix: key the check_fn cache by the same served-profile predi
|
Supersedes community fixes #111354 and #111158 for #111151 (same dashboard-served-profile MCP connection scoping). Cross-linking so reviewers compare the three approaches in one place; this core-team PR keys the connection on the served HERMES_HOME override without enabling the global multiplex flag. |
andrexibiza
left a comment
There was a problem hiding this comment.
I reviewed exact head 4fb175597a3633a84ace6753aa175f0e810960a1 against its actual merge base 2179a279ae04bfadf8efbc49a01ca0abfb738000 and current main dfc28b61a0cfed58bcc200038c6bfec6f31adcd2. This carrier is one commit ahead / 25 commits behind current main; the intervening main delta does not touch the three changed paths here, so I do not see a merge-order blocker from that staleness.
The connection-key part is narrow and correct as far as it goes. #108352 is the merged foundation for profile-qualified MCP ledgers under the real gateway multiplexer, and this patch correctly extends the same connection identity to dashboard/Desktop/per-profile-cron execution that already has an explicit routed HERMES_HOME even when gateway.multiplex_profiles is false. The normalization back to the bare key when the override names the process home also avoids gratuitously splitting the default profile from its startup-warm connection.
There is still one P1 authority-boundary blocker before this can honestly close #111151 or supersede the community carriers as a complete fix: the patch upgrades the routed profile into MCP connection identity without upgrading the same routed profile into credential authority.
At this exact head, agent.secret_scope.get_secret() still behaves differently from _mcp_registry_scope(): when _MULTIPLEX_ACTIVE is false, an installed profile secret scope that does not contain a requested key falls through to ambient os.environ; with no secret scope installed at all, it also falls through to ambient os.environ. The dashboard's _hermes_home_scope / _config_profile_scope installs only the context-local HERMES_HOME route. So profile B can now get the correct (B, server) connection slot while ${MCP_TOKEN} still resolves to the dashboard launch profile A's token. That can produce a distinct B-keyed connection authenticated as A, or—when the resolved route/fingerprint matches—permit B to reuse/adopt A's credentialed connection. The user-visible failure remains the same class: a B session can call the external service as A instead of failing closed.
That is not hypothetical scope expansion beyond the report: #111151 itself calls out that secret_scope fails open on this dashboard path because the multiplex flag is not set and explicitly requires the system to fail loudly rather than return another profile's data. The new regression test cannot witness this because it starts from already-resolved literal Bearer A / Bearer B configs; it proves ledger separation after credential resolution, not the credential-resolution authority that feeds the ledger.
The required invariant is: an explicit cross-profile HERMES_HOME route must bind connection identity and secret lookup authority together. Preserve ambient env fallback for a genuinely unscoped single-profile process, but once execution is routed to another profile, a missing or absent profile secret scope must not inherit the launch profile's credential. Please add the negative witness through the real ${MCP_TOKEN} resolver: keep ambient MCP_TOKEN=launch-token, route to profile B with is_multiplex_active=False, omit B's token, cover both an empty mapping and no mapping installed, and prove B cannot resolve launch-token or use/adopt A's connection.
Topology / provenance matters here because this PR is now explicitly presented as the superseding core carrier. #111158 (KoNit-K) is adjacent/partial: it correctly identified the inert dashboard secret-mode seam and installs profile secret scope, but derives strictness from gateway.multiplex_profiles, so it is insufficient for the reported flag-false configuration. #111354 (JoaoMarcos44) is a competing/superseded carrier for the same #111151 defect class, not unrelated work: it independently carries the same routed connection-key fix plus cache scoping and, after review, added a dedicated routed-profile secret-authority change. Its latest follow-up still has its own missing-map residual, so I am not treating it as already correct; however, if #111481 is the chosen carrier, the authority invariant and negative resolver coverage discovered there should be salvaged rather than lost under a blanket "superseded" closure. #108352 remains the merged foundation; #106005/#91654 are lineage for the qualified MCP state machinery, not duplicate carriers.
Acceptance is strong: exact-head CI 34920757233, Docker 34920756616, and Nix 34920756609 all completed successfully. This is a one-commit carrier, so every surviving implementation commit is also hosted-green. That clears acceptance risk; it does not clear the semantic P1 above.
Aside from that credential-authority mismatch, I did not find another blocker in the three-path patch. The connection-key projection is appropriately small, the current-main drift is path-disjoint, and the exact head is green. Close the routed-secret half of the authority boundary with the negative witness and this becomes a coherent core closure for #111151.
| 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() |
There was a problem hiding this comment.
P1 — connection identity becomes profile-qualified here, but credential authority is still ambient in this exact mode. With is_multiplex_active()==False, agent.secret_scope.get_secret() still falls through to os.environ when the routed scope lacks a key, and also when no secret scope is installed. The dashboard route contexts install HERMES_HOME, so profile B can receive its own (B, name) connection key while ${MCP_TOKEN} still resolves to launch profile A's token. That preserves the wrong-account failure class #111151 is trying to eliminate. Please bind secret lookup authority to this explicit routed identity too, while keeping ambient fallback only for genuinely unscoped single-profile execution.
| 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"}} |
There was a problem hiding this comment.
This test starts from already-resolved literal Bearer A / Bearer B configs, so it proves ledger separation but bypasses the credential resolver that still leaks ambient authority. Please add the negative witness through _resolve_mcp_server_config: keep ambient MCP_TOKEN=launch-token, route to B with multiplex flag off, omit B's token, test both an empty secret mapping and no mapping installed, and prove B cannot resolve launch-token or use/adopt A's connection.
…he MCP registry scope _mcp_registry_scope() became profile-keyed for served profiles with the multiplex flag off, but check_fn_cache_scope() still returned None in that mode, so the process-wide availability cache stayed keyed (fn, None) across profiles. A served profile whose mcp__x__* check_fn now correctly resolves to its own (absent) connection cached False for the TTL window and the launch profile that owns the live connection lost its tools for that window. Both sites now call one helper, agent.secret_scope.serves_routed_profile() (multiplex on, or a HERMES_HOME override naming a home other than the process home), so the registry scope and the cache key can no longer drift. Review finding: served profile B's check_fn verdict shadowed the launch profile's live mcp tools via the unscoped check_fn cache.
ehz0ah
left a comment
There was a problem hiding this comment.
Exact-head follow-up found one additional P1 lifecycle boundary. The earlier credential-authority finding remains valid and is not repeated here. I reproduced this separate case with the repository test runner on 4fb175597a3633a84ace6753aa175f0e810960a1.
| 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()): |
There was a problem hiding this comment.
[P1] Keep the default connection owner distinct from wildcard teardown. This branch returns None for an explicit override that resolves to the process home. reconcile_mcp_servers_with_config() then passes scope=None, names={"x"} to shutdown_mcp_servers(), where scope is None selects every owner with that server name. With bare default key "x" and profile B key (scope_b, "x"), removing only the default config entry shuts down both sessions and removes B's connection. I reproduced this through the repository test runner at this exact head. Both shutdown callbacks ran and B's scoped key disappeared. Preserve startup reuse of the bare key, but carry an exact default-owner selector or exact keys into teardown. Add a default-plus-named same-server reconciliation test that proves B's session and bookkeeping remain untouched.
ehz0ah
left a comment
There was a problem hiding this comment.
Follow-up review of exact head c36dd99e522d20246426d8b701f759ce383c00fd. The new commit correctly keys the check_fn cache with the same served-profile predicate as the MCP connection registry. All 8 submitted regression tests pass at this head and in a synthetic merge over current main.
Two existing P1 blockers remain unresolved:
- Routed profiles can still inherit the launch-profile credential. The related resolver test gap also remains. With ambient
MCP_TOKENset, both an empty routed secret scope and no routed secret scope still return the launch-profile token. - Default-profile reconciliation still uses the wildcard teardown selector. Removing default server
xstill shuts down both the barexconnection and a named-profile(scope, x)connection.
I reran three focused negative cases against this exact head and the synthetic current-main merge. All three still fail in the buggy direction. The adjacent secret-scope and reconcile suites pass 27 tests in both trees. A current-main cron control passes because routed cron execution installs _MULTIPLEX_CONTEXT, but the dashboard profile scope still does not install that context, so the reported dashboard path remains exposed.
git diff --check is clean, the review worktree is clean, and all required hosted checks are green. The cache change is good, but both linked authority and teardown boundaries need fixes before this closes #111151.
|
Review notes at head c36dd99 (read-only; two things I'd fix before merge, both verified against the head):
Simplest whole-class fix now that dbede34 is on main: call Disposition of the externals for #111151: #111354 (@JoaoMarcos44, earliest submitter — worth a credit line) has the same None-normalisation hole and a |
…lip routed-profile decisions Symptom: a host that serves several profiles from one process and mirrors the active turn's profile into `os.environ["HERMES_HOME"]` for legacy readers (Hermes WebUI does this on every chat turn, next to the context-local override) makes every launch-home decision see the served profile as the launch profile. Two profiles that both configure `atlassian` with different credentials share whichever MCP connection came first: a READ_ONLY_MODE=false profile ends up calling a read-only server (nesquena/hermes-webui#7721). The same misjudgement leaves the launch residue in the served profile's child env, seeds the launch profile's bridged allow-all grant into the served profile's secret scope, and lets the served profile's `terminal.*` config bridge into the shared process env. Cause: four launch-home checks compare the task's override with `get_process_hermes_home()`, which reads `HERMES_HOME` live: `agent.secret_scope.serves_routed_profile` (keys the MCP ledger via `_mcp_registry_scope`, NousResearch#108352 / NousResearch#111481, and the check_fn cache, NousResearch#111151), `agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home` and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two sides are equal for every turn. Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the host record the home it serves as its own; `get_routing_process_hermes_home()` returns the pin when set, else `get_process_hermes_home()`; the four checks compare against it. The pin is deliberately NOT folded into `get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks carrying no override (MCP loop, spawners), and the host's mirror exists precisely so those readers see the served profile. Only "is this task routed / is this the launch home" changes. Unpinned, behaviour is byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not call it. `activate_multi_profile_hosting()` is not the seam for this: it flips `get_secret` fail-closed process-wide and freezes the launch env, which an embedding host cannot adopt as a bug fix. Tests (2 invariants, parametrized over the four checks plus the MCP ledger key; red on main, green here): pinned + mirrored env -> the served home is routed and the launch home is not, the MCP key is `(home_key, name)`, `get_process_hermes_home()` still follows the env var; never pinned or pinned-then-cleared -> old semantics, including "a mirrored env var IS the launch home". `tests/conftest.py` resets the pin per test so the module-global cannot leak between files. Live repro (WebUI + a stdio FastMCP server named `atlassian` in two profiles, one gated by READ_ONLY_MODE): base -> one ledger key `'atlassian'`, the write profile lists only the read-only tools; fixed -> `(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`, each profile lists its own tools. Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the isolation table in `website/docs/user-guide/multi-profile-gateways.md`. Also maps the author e-mail under contributors/emails/ (attribution check). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lip routed-profile decisions Symptom: a host that serves several profiles from one process and mirrors the active turn's profile into `os.environ["HERMES_HOME"]` for legacy readers (Hermes WebUI does this on every chat turn, next to the context-local override) makes every launch-home decision see the served profile as the launch profile. Two profiles that both configure `atlassian` with different credentials share whichever MCP connection came first: a READ_ONLY_MODE=false profile ends up calling a read-only server (nesquena/hermes-webui#7721). The same misjudgement leaves the launch residue in the served profile's child env, seeds the launch profile's bridged allow-all grant into the served profile's secret scope, and lets the served profile's `terminal.*` config bridge into the shared process env. Cause: four launch-home checks compare the task's override with `get_process_hermes_home()`, which reads `HERMES_HOME` live: `agent.secret_scope.serves_routed_profile` (keys the MCP ledger via `_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151), `agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home` and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two sides are equal for every turn. Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the host record the home it serves as its own; `get_routing_process_hermes_home()` returns the pin when set, else `get_process_hermes_home()`; the four checks compare against it. The pin is deliberately NOT folded into `get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks carrying no override (MCP loop, spawners), and the host's mirror exists precisely so those readers see the served profile. Only "is this task routed / is this the launch home" changes. Unpinned, behaviour is byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not call it. `activate_multi_profile_hosting()` is not the seam for this: it flips `get_secret` fail-closed process-wide and freezes the launch env, which an embedding host cannot adopt as a bug fix. Tests (2 invariants, parametrized over the four checks plus the MCP ledger key; red on main, green here): pinned + mirrored env -> the served home is routed and the launch home is not, the MCP key is `(home_key, name)`, `get_process_hermes_home()` still follows the env var; never pinned or pinned-then-cleared -> old semantics, including "a mirrored env var IS the launch home". `tests/conftest.py` resets the pin per test so the module-global cannot leak between files. Live repro (WebUI + a stdio FastMCP server named `atlassian` in two profiles, one gated by READ_ONLY_MODE): base -> one ledger key `'atlassian'`, the write profile lists only the read-only tools; fixed -> `(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`, each profile lists its own tools. Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the isolation table in `website/docs/user-guide/multi-profile-gateways.md`. Also maps the author e-mail under contributors/emails/ (attribution check). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lip routed-profile decisions Symptom: a host that serves several profiles from one process and mirrors the active turn's profile into `os.environ["HERMES_HOME"]` for legacy readers (Hermes WebUI does this on every chat turn, next to the context-local override) makes every launch-home decision see the served profile as the launch profile. Two profiles that both configure `atlassian` with different credentials share whichever MCP connection came first: a READ_ONLY_MODE=false profile ends up calling a read-only server (nesquena/hermes-webui#7721). The same misjudgement leaves the launch residue in the served profile's child env, seeds the launch profile's bridged allow-all grant into the served profile's secret scope, and lets the served profile's `terminal.*` config bridge into the shared process env. Cause: four launch-home checks compare the task's override with `get_process_hermes_home()`, which reads `HERMES_HOME` live: `agent.secret_scope.serves_routed_profile` (keys the MCP ledger via `_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151), `agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home` and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two sides are equal for every turn. Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the host record the home it serves as its own; `get_routing_process_hermes_home()` returns the pin when set, else `get_process_hermes_home()`; the four checks compare against it. The pin is deliberately NOT folded into `get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks carrying no override (MCP loop, spawners), and the host's mirror exists precisely so those readers see the served profile. Only "is this task routed / is this the launch home" changes. Unpinned, behaviour is byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not call it. `activate_multi_profile_hosting()` is not the seam for this: it flips `get_secret` fail-closed process-wide and freezes the launch env, which an embedding host cannot adopt as a bug fix. Tests (2 invariants, parametrized over the four checks plus the MCP ledger key; red on main, green here): pinned + mirrored env -> the served home is routed and the launch home is not, the MCP key is `(home_key, name)`, `get_process_hermes_home()` still follows the env var; never pinned or pinned-then-cleared -> old semantics, including "a mirrored env var IS the launch home". `tests/conftest.py` resets the pin per test so the module-global cannot leak between files. Live repro (WebUI + a stdio FastMCP server named `atlassian` in two profiles, one gated by READ_ONLY_MODE): base -> one ledger key `'atlassian'`, the write profile lists only the read-only tools; fixed -> `(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`, each profile lists its own tools. Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the isolation table in `website/docs/user-guide/multi-profile-gateways.md`. Also maps the author e-mail under contributors/emails/ (attribution check). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lip routed-profile decisions Symptom: a host that serves several profiles from one process and mirrors the active turn's profile into `os.environ["HERMES_HOME"]` for legacy readers (Hermes WebUI does this on every chat turn, next to the context-local override) makes every launch-home decision see the served profile as the launch profile. Two profiles that both configure `atlassian` with different credentials share whichever MCP connection came first: a READ_ONLY_MODE=false profile ends up calling a read-only server (nesquena/hermes-webui#7721). The same misjudgement leaves the launch residue in the served profile's child env, seeds the launch profile's bridged allow-all grant into the served profile's secret scope, and lets the served profile's `terminal.*` config bridge into the shared process env. Cause: four launch-home checks compare the task's override with `get_process_hermes_home()`, which reads `HERMES_HOME` live: `agent.secret_scope.serves_routed_profile` (keys the MCP ledger via `_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151), `agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home` and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two sides are equal for every turn. Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the host record the home it serves as its own; `get_routing_process_hermes_home()` returns the pin when set, else `get_process_hermes_home()`; the four checks compare against it. The pin is deliberately NOT folded into `get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks carrying no override (MCP loop, spawners), and the host's mirror exists precisely so those readers see the served profile. Only "is this task routed / is this the launch home" changes. Unpinned, behaviour is byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not call it. `activate_multi_profile_hosting()` is not the seam for this: it flips `get_secret` fail-closed process-wide and freezes the launch env, which an embedding host cannot adopt as a bug fix. Tests (2 invariants, parametrized over the four checks plus the MCP ledger key; red on main, green here): pinned + mirrored env -> the served home is routed and the launch home is not, the MCP key is `(home_key, name)`, `get_process_hermes_home()` still follows the env var; never pinned or pinned-then-cleared -> old semantics, including "a mirrored env var IS the launch home". `tests/conftest.py` resets the pin per test so the module-global cannot leak between files. Live repro (WebUI + a stdio FastMCP server named `atlassian` in two profiles, one gated by READ_ONLY_MODE): base -> one ledger key `'atlassian'`, the write profile lists only the read-only tools; fixed -> `(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`, each profile lists its own tools. Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the isolation table in `website/docs/user-guide/multi-profile-gateways.md`. Also maps the author e-mail under contributors/emails/ (attribution check). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lip routed-profile decisions Symptom: a host that serves several profiles from one process and mirrors the active turn's profile into `os.environ["HERMES_HOME"]` for legacy readers (Hermes WebUI does this on every chat turn, next to the context-local override) makes every launch-home decision see the served profile as the launch profile. Two profiles that both configure `atlassian` with different credentials share whichever MCP connection came first: a READ_ONLY_MODE=false profile ends up calling a read-only server (nesquena/hermes-webui#7721). The same misjudgement leaves the launch residue in the served profile's child env, seeds the launch profile's bridged allow-all grant into the served profile's secret scope, and lets the served profile's `terminal.*` config bridge into the shared process env. Cause: four launch-home checks compare the task's override with `get_process_hermes_home()`, which reads `HERMES_HOME` live: `agent.secret_scope.serves_routed_profile` (keys the MCP ledger via `_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151), `agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home` and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two sides are equal for every turn. Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the host record the home it serves as its own; `get_routing_process_hermes_home()` returns the pin when set, else `get_process_hermes_home()`; the four checks compare against it. The pin is deliberately NOT folded into `get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks carrying no override (MCP loop, spawners), and the host's mirror exists precisely so those readers see the served profile. Only "is this task routed / is this the launch home" changes. Unpinned, behaviour is byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not call it. `activate_multi_profile_hosting()` is not the seam for this: it flips `get_secret` fail-closed process-wide and freezes the launch env, which an embedding host cannot adopt as a bug fix. Tests (2 invariants, parametrized over the four checks plus the MCP ledger key; red on main, green here): pinned + mirrored env -> the served home is routed and the launch home is not, the MCP key is `(home_key, name)`, `get_process_hermes_home()` still follows the env var; never pinned or pinned-then-cleared -> old semantics, including "a mirrored env var IS the launch home". `tests/conftest.py` resets the pin per test so the module-global cannot leak between files. Live repro (WebUI + a stdio FastMCP server named `atlassian` in two profiles, one gated by READ_ONLY_MODE): base -> one ledger key `'atlassian'`, the write profile lists only the read-only tools; fixed -> `(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`, each profile lists its own tools. Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the isolation table in `website/docs/user-guide/multi-profile-gateways.md`. Also maps the author e-mail under contributors/emails/ (attribution check). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dashboard/Desktop backend serving several profiles gives each profile its own MCP connection, so a profile's tools never call the server with another profile's credentials.
Fixes #111151
tools/mcp_tool.py::_mcp_registry_scope— a process serving a routed profile through the HERMES_HOME override (dashboard/Desktop backend, per-profile cron ticker) now uses the same per-profile connection key as the multiplexer, even withgateway.multiplex_profilesoff. Every ledger already keys through_mcp_registry_scope(_server_key,_resolve_server_key,_select_new_servers, status, registry overlays), so no other site changes. A process with no override, or an override naming its own home, keeps the bare key — single-profile behaviour is byte-identical.tests/tools/test_mcp_multiplex_connection_keys.py(multiplex flag off, two served homes, same server name with differentAuthorizationheaders).multi-profile-gateways.mdserved-profile table.Live repro:
probe_mux.py— multiplex off, profile A discoverszernio(Bearer A) under its home override, then profile B's session resolves the same name_resolve_server_key("zernio")'zernio'→ A's connection, headersBearer A(<B home>, 'zernio')→ no connection yetmcp-zerniotools before its own discovery[]"zernio" in _select_new_servers(B config)False(silently reuses A)True(B connects with its own credentials)get_mcp_statusconnectedconnectingconnectedconnected(unchanged)Root cause:
_mcp_registry_scope()returnedNonewheneveris_multiplex_active()was false, and nothing underhermes_cli//tui_gateway/sets that flag, so the profile-keyed connections added for #106005/#91654 were inert on exactly the surface that serves several profiles in one process.Credit
#111158 by @KoNit-K located the inert flag on the dashboard; its approach (flipping the process-wide fail-closed secret mode from
config.yamlinweb_server.start_server) is not taken — the leak is the connection key, and the multiplex secret mode changes credential resolution for every code path in the dashboard process. Policy: isolation is between profiles via the home scope; no environ fallthrough is added.Infographic
Review fixes
c36dd99):check_fn_cache_scope()still returnedNonewith multiplex off, so a served profile's (correct) "not my connection" verdict was cached under the launch profile's key and the owner lost itsmcp__x__*tools for the TTL window. Both_mcp_registry_scope()andcheck_fn_cache_scope()now call one shared helper,agent.secret_scope.serves_routed_profile(). Reviewer probeprobe_481b.py:False / False / []→False (scope='/tmp/…B') / True / ['mcp__x__t']. New testtest_served_profile_check_fn_verdict_does_not_shadow_launch_profile(red before, green after); mcp/registry/secret_scope suites 226 passed.