mcp: scope the server registry per profile — two profiles naming the same server share one subprocess and its credentials - #99594
Izzy-Gottz wants to merge 1 commit into
Conversation
A multiplexing gateway serves many profiles from one process. Every
inbound turn runs inside `_profile_runtime_scope`, which redirects
HERMES_HOME and installs that profile's `.env` through the fail-closed
`agent/secret_scope.py`. The MCP layer never honoured that scope: its
registries were module-level dicts keyed by the BARE server name, so two
profiles that each configure `github` shared ONE subprocess — started
with whichever profile connected first, holding that profile's
credentials. Profile B's turns reached profile A's GitHub account.
`_lazy_server_configs` is the sharpest edge: `_load_mcp_config`
interpolates `${VAR}` refs through the active profile's secret scope
BEFORE the config is cached, so an entry holds that profile's resolved
token verbatim and would spawn another profile's first call with it.
Partition every one of those registries by profile. The key comes from
the same scope the rest of the multiplexed path uses — the context-local
HERMES_HOME override installed alongside `set_secret_scope` — resolved
through `hermes_constants.hermes_home_key`, mirroring the idiom already
in `tools/registry.py::check_fn_cache_scope` and
`hermes_cli/plugins.py::_plugin_home_key`. There is no second notion of
"who is this".
Single-profile behaviour is unchanged by construction: the key is the
empty string whenever `is_multiplex_active()` is false, so every access
lands in one partition. Gating on the multiplex flag (not merely on "is
an override set") is deliberate — `hermes_cli/plugins`, the
`mcp_startup` discovery thread, `bot_mode_probe` and the desktop backend
all install home overrides in single-profile processes, and partitioning
on those would strand a server behind a key nothing looks up.
Partitioned: _servers, _server_connecting, _server_connect_errors,
_lazy_server_configs, _lazy_server_fingerprints, _lazy_server_tool_names,
_server_connect_retry_after, _server_connect_failures,
_server_error_counts, _server_breaker_opened_at, _server_trust_levels,
_tool_read_only_hints, _parallel_safe_servers. The trust maps matter as
much as `_servers`: inheriting another profile's `trust: full` or its
readOnlyHint exemptions silently un-gates the dangerous-call approval.
Lifecycle consistency:
* `MCPServerTask` captures its owning `_profile_key` at construction
and uses it for self-eviction and post-reconnect tool publication —
a long-lived task must address its own partition, not whatever
context the loop carries.
* `shutdown_mcp_servers` captures the key on the CALLER's context;
`run_coroutine_threadsafe` does not copy contextvars onto the MCP
loop, so the inner coroutine addresses partitions explicitly.
* The `only_if_idle` guard in `_stop_mcp_loop` now asks ALL partitions.
The event loop is process-global; asking only the active profile
would tear it out from under another tenant's live servers.
* New `shutdown_mcp_servers(profile_only=True)` reaps one profile and
leaves the rest running. Default stays process-wide, so process exit
and the CLI's /reload-mcp are untouched.
* The gateway's /reload-mcp (`_execute_mcp_reload`) is chat-triggered
from inside one profile's scope: it now uses `profile_only=True` and
`copy_context()` around the executor hops. Without the latter the
worker thread lost the override and re-discovered the DEFAULT
profile's `mcp_servers`; without the former one tenant typing
/reload-mcp tore down (and SIGKILLed the stdio children of) every
other tenant's servers.
Tests: tests/tools/test_mcp_profile_isolation.py — the two-profile
collision, the lazy-config credential cache, the registration gate,
per-profile connect errors/cooldowns/trust/breaker, that a single-profile
gateway keeps exactly one partition even under a home override, that an
unscoped read under multiplexing gets an isolated partition rather than
aliasing a profile, and that profile teardown leaves another profile's
server running while full shutdown still reaps every one.
Known and NOT fixed here (same shape, wider blast radius): MCP tools are
registered into `tools/registry.py` with no `scope=`, so the advertised
schemas and `_mcp_tool_server_names` remain process-global — execution is
profile-correct after this change, but tool names/descriptions still
cross profiles. `_mcp_stderr_log_fh` pins every profile's subprocess
stderr into the first profile's `~/.hermes/logs/`.
`hermes_cli/mcp_startup.py`'s once-per-process discovery gate lets only
the first profile discover at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwLrUDEK8xRBbEh5CshtAQ
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head 2b871d4dd3cc34b30a9fad52f661fa68f9e48086 against base 59c5bd091332c51673d01b6639d2a7978a91e5d4, including the full 3-file diff, the 12 new isolation tests, current registry implementation, exact-head Actions, and the live MCP/profile PR/issue graph. The core diagnosis is correct, and there is good work here: partitioning the live connection, lazy interpolated config, trust/read-only metadata, breaker/backoff state, and carrying an explicit owner into long-lived MCPServerTask lifecycle closes a real cross-profile credential/authority leak. The profile_only loop-idleness check is also the right shape: the profile is local ownership, while the MCP loop is process ownership.
I do not think this exact head is safe to land yet, for three concrete reasons.
P1 — the mutation/runtime half is scoped, but the model-visible registry half is still process-global
This is acknowledged in the PR body, but it is not a harmless follow-up for this defect class. tools/registry.py::ToolRegistry.register(..., scope=None) writes into process-global _tools; _merged_tools() then includes _tools for every profile. The MCP registration paths on this head still call registry.register(...) without scope=, so a dynamic MCP tool discovered under profile A remains visible to profile B in schemas/names/descriptions and can replace the same globally named entry registered by B.
The important case is not merely metadata cosmetics. Let profiles A and B both name a logical server github, but return different schemas for the same raw tool name (or disjoint tool inventories). The process-global ToolEntry is whichever registration won last, while the handler on this branch re-resolves the server through the current profile partition. That permits a split-brain operation: model-visible schema/description/handler closure provenance from one profile, execution transport from the other. At minimum that leaks capability metadata and advertises tools a profile does not own; for same-named tools with different contracts it can drive arguments against a different profile's server under the wrong schema.
Please close the boundary as one ownership contract: dynamic MCP registrations, deregistration/refresh, toolset lookup/aliases, definitions, and dispatch must use the same canonical profile owner as _servers. _mcp_tool_server_names then needs to move with that owner (or disappear as redundant state). Add a real registry regression with two profiles, same server name, and deliberately different tool inventories plus deliberately different schemas for one same-named tool; prove A → B → A definitions and dispatch resolve the matching profile every time. Also prove an unscoped multiplex lookup cannot select either profile.
There is substantial prior work here that should be consolidated rather than silently reimplemented. #80746 is the earlier/open same-defect-class implementation: it scopes runtime and central registry by canonical profile home, including schema/handler/status/dispatch, and it was updated on Aug 30 with a production reproduction of wrong-profile authenticated MCP transport. This PR has useful newer mechanics and a narrower container approach; #80746 has unique registry-side coverage. Treat them as overlapping implementations with salvage/credit on both sides, not as unrelated siblings or as a reason to discard either contributor's work.
P1 — cold-start discovery is still on the other side of the boundary
This head fixes profile-routed /reload-mcp, but a fresh multiplexed gateway still needs a path that populates each profile's partition. #95518 documents the current failure: gateway boot discovery reads the default home, the turn path only snapshots the registry, and a profile-only mcp_servers config can remain undiscovered until some unrelated scoped path happens to run. #95542 is the current complementary implementation for exactly that surface: per-profile gateway boot discovery, executor-context propagation, profile-local registration/shutdown, and a gateway-level regression.
The new tests/tools/test_mcp_profile_isolation.py is strong for container ownership, but it constructs fake servers directly and does not execute gateway boot discovery or _execute_mcp_reload. Since this PR changes gateway/run.py, please add/compose a gateway integration test that starts from empty MCP state, supplies two served profile homes, and proves both profile configs are discovered without a cron/reload side effect; then prove /reload-mcp for B cannot tear down A and cannot repopulate from the default home. The cleanest route may be to consolidate the distinct pieces of #95542 rather than create a third implementation of its gateway half. Preserve #95542's contributor credit for that path.
#96147 is adjacent rather than duplicate: it adds authenticated-requester identity below the profile boundary for OAuth and has already had to solve scoped reload/cache teardown cases. Its requester dimension should remain a refinement of (profile owner, server) rather than being flattened by whichever profile solution lands.
Merge-order / ownership blocker — this head writes through an active mcp_tool.py sharding owner
The FILE-LIST is not clean. #84070 is an open, recently updated byte-verbatim extraction whose exact ownership window includes _server_connect_retry_after and _server_connect_failures, moving those names into tools/mcp_connect_cooldown.py. This PR changes those exact state owners in-place and adds a large new profile-container implementation to tools/mcp_tool.py, which is already far beyond the repository's 2K decomposition boundary.
Please resolve that land order rather than making one branch invalidate the other's seam contract. If #84070 lands first, the profile-keyed cooldown state belongs in the extracted bounded owner (or in a dedicated profile-state module) and this branch should consume/re-export it. If this work lands first, #84070 needs an explicit restack/new golden against the new state shape while preserving its authorship and extraction provenance. More broadly, the profile partition container itself is coherent enough to be a bounded owner; it should not become hundreds of additional lines of permanent godfile surface.
Acceptance state
The PR reports useful local before/after suites, but there is no hosted exact-head acceptance receipt yet. At 2b871d4dd3cc34b30a9fad52f661fa68f9e48086, CI, Docker, and Nix are all currently action_required (not green runs). That is not evidence of a code failure, but it does mean this commit is not acceptance-green yet.
I would keep the partition/lifecycle work — especially the explicit MCPServerTask._profile_key, lazy-config isolation, trust-map isolation, and process-global idle check. Those are real fixes. The landing unit just needs to finish the same identity all the way through discovery → registry/schema → live connection → dispatch → teardown, reconcile the existing owners (#80746, #95542, #96147, #84070), and then get exact-head hosted green. That will turn this from a strong partial isolation fix into one coherent profile-ownership boundary.
|
Thank you — this is a more careful read than the PR deserved, and the split-brain argument lands. On P1 (registry). You are right and I was wrong to file it as a follow-up. I reasoned "execution is profile-correct, so the rest is metadata," and that stops being true exactly where you point: with the handler re-resolving the server per partition but the On overlap. I did not know about #80746, #95542 or #96147 when I wrote this, and having read them now, I do not think a fourth implementation helps anyone. #80746 covers the registry side I left open and has a production reproduction; #95542 covers gateway boot discovery, which my tests deliberately avoid because they construct fake servers rather than exercising discovery. I would rather this be salvaged into those than land beside them. Concretely, what looks uniquely useful here is the explicit On #84070. Agreed that this head writes through its extraction window, and I would rather not invalidate its seam. My preference is that #84070 lands first and the profile-keyed cooldown state goes into the extracted owner rather than staying in On acceptance. Understood that local before/after is not a hosted receipt. There are no checks reported on the head at all, which I take to be the first-time-contributor approval gate rather than a result — if you approve a run I will chase whatever it surfaces. So: happy to do the discovery → registry/schema → connection → dispatch → teardown work as one boundary with a real gateway integration test, but only in whichever shape avoids duplicating #80746 and #95542. Tell me the landing order you want and I will follow it. |
Adapt the per-run MCP metadata transport from NousResearch#64938 to the current Runs and MCP handler modules. Target exact configured servers, validate bounded JSON, preserve metadata across retries and native delegation, and advertise the API contract. Add HTTP-to-MCP SDK coverage and document the trust boundary. Bind the originating profile with the immutable run metadata. Refuse unknown, foreign, or replaced MCP destinations before lazy acquisition, queued dispatch, and recovery; do not retarget credentials when a child enters another profile. Use the canonical protocol-owned metadata predicate. The fail-closed guard is not a replacement for the profile-qualified MCP registry proposed in NousResearch#99594. Based on rainbowgits' contribution in astraltrekkin/hermes-agent; retained authorship and adapted implementation, tests, and documentation by Diadems Tech. (cherry picked from commit 36ed7f3) Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Diadems Tech <194491654+DiademsTech@users.noreply.github.com>
|
Heads-up, @Izzy-Gottz: the gateway half of this is on main. ee0e234 runs MCP discovery once per served profile inside What main did not take from this PR — and why it stays open — is the registry partition itself: |
|
Superseded by #108352 (on |
The problem
The multiplexing gateway scopes config, memory and secrets per profile:
_profile_runtime_scopeinstalls aHERMES_HOMEoverride alongsideset_secret_scope, andagent/secret_scope.pyis deliberately fail-closed so an un-migrated call site raises rather than silently reading another profile's value.The MCP layer never joined that scheme. Thirteen module-level structures in
tools/mcp_tool.pyare keyed by bare server name, so two profiles that each configure a server calledgithubshare one subprocess — started by whichever arrived first, holding that profile's credentials.A test written against unmodified code shows it directly:
Two things make it worse than a shared connection:
_lazy_server_configscaches a config whose${TOKEN}placeholders are already expanded._load_mcp_configinterpolates through the active secret scope before caching, so the cached dict literally holds the first profile's credential and would spawn the second profile's first call with it.trust: fullor itsreadOnlyHintexemptions silently un-gates the dangerous-call approval path.The fix
_mcp_profile_key()partitions every one of those structures:"", one partition, today's behaviour;hermes_home_key(get_hermes_home_override()), the same override_profile_runtime_scopeinstalls;get_secretdoes).This mirrors
tools/registry.py::check_fn_cache_scopeandhermes_cli/plugins.py::_plugin_home_keyrather than introducing a second notion of identity. Gating onis_multiplex_active()rather than on "an override is set" is load-bearing: plugins, discovery, the bot-mode probe and the desktop backend all set overrides in single-profile processes, and partitioning on those would strand a server behind a key nothing looks up._ProfileScopedDict/_ProfileScopedSetimplement the fullMutableMapping/MutableSetprotocols, so the ~90 existing call sites and test fixtures are unchanged;partitions()/for_key()are the escape hatch for genuinely process-global work.Lifecycle.
MCPServerTaskcaptures its key at construction, because the long-livedrun()task must address its own partition.shutdown_mcp_serverscaptures on the caller's context —run_coroutine_threadsafedoes not copy contextvars onto the MCP loop._stop_mcp_loop(only_if_idle=True)deliberately asks all partitions: the loop is process-global, and a per-profile check would let one profile's probe tear it out from under another's live servers.Also fixed:
gateway/run.py::_execute_mcp_reloadranshutdown_mcp_serversprocess-wide from inside one profile's scope, so one tenant typing/reload-mcptore down every other tenant's stdio children — and, contextvars not propagating throughrun_in_executor, then re-discovered the default profile'smcp_servers.Single-profile behaviour is unchanged
Three ways: by construction (the key is
""in any non-multiplexed process, so there is exactly one partition and every default is preserved); by explicit test (test_single_profile_gateway_is_unpartitionedasserts one partition even under aHERMES_HOMEoverride); and by baseline diff — the same suites on the base commit and on this branch:Same failing files, same counts, both sides; those failures are pre-existing and platform-specific (macOS).
Known siblings, deliberately not fixed here
Reported rather than swept in, so this stays reviewable:
scope=, so they land in the process-global_toolsrather than_scoped_tools. Execution is profile-correct after this change (handlers re-resolve by name), but advertised schemas, names and descriptions still cross profiles._scoped_toolsalready does this for plugins — the natural follow-up._mcp_stderr_log_fhresolves once under whichever profile calls first, then becomes every profile's stdio child's stderr.hermes_cli/mcp_startup.py's_mcp_discovery_startedis a once-per-process gate, so profiles 2..N get no MCP tools. Same shape intui_gateway/entry.py._mcp_tool_server_namesis left unpartitioned on purpose — it is meaningless apart from the global registry, and partitioning it alone would makehas_registered_mcp_tools()lie. Fix with (1)._MCP_DISCOVERY_LOCK_PATH; 6.mcp_oauth.py::_reserved_sockets(a global FIFO cap of 8, so one profile's OAuth burst can evict another's parked callback socket); 7.model_tools.py::_last_resolved_tool_names.Happy to split any of those out if you'd rather see them separately.
Notes for review
_profile_keyonMCPServerTaskcosts a__slots__entry. Deliberate — trusting contextvar inheritance into the long-livedrun()task is the kind of thing that breaks silently later._mcp_profile_key()runs on every registry access including per-toolcheck_fnevaluation, so the two lazy imports are memoized at module level — the module is cached, not the functions, so monkeypatchingis_multiplex_activestill takes effect.tydiff stays quiet; the blockingruffrules are untouched.