Files
hermes-agent/tools/mcp_tool_scope.py
Teknium ceaf622c6d fix(mcp): same-named MCP servers with different credentials connect per profile; owner /reload-mcp keeps adopters' tools
Under gateway.multiplex_profiles every connection ledger in tools/mcp_tool.py
(_servers, _server_scope_keys/_server_tool_scopes, connecting/error/cooldown
maps, the circuit breaker, lazy schema-cache configs, trust metadata) was keyed
by the bare server NAME. The common per-tenant layout — each profile names its
server `github`/`notion` with its own token — gave only the first profile a
connection: the second profile's register_mcp_servers saw the name as "already
connected", refused to adopt it (different credentials, 4ddbcbd35e), and left
the profile silently tool-less with a healthy-looking `configured` status
(#106005 Bug 1/2, #91654). Siblings of the same bug: profile A's failing `x`
put profile B's healthy `x` into A's 10-minute connect cooldown and A's open
circuit breaker short-circuited B's calls; toolsets._resolve_toolset_memo was
not scope-keyed, so B resolved A's `mcp-<server>` tool names.

Keys are now the connection key from the new tools/mcp_tool_scope.py: the bare
name outside a multiplexer (single-profile processes are unchanged) and
(owner_scope, name) under one. Call-time lookups (_resolve_server_key) prefer
the calling scope's own connection, then a shared connection it adopted, so
identical-route profiles still share one subprocess. _select_new_servers,
the cooldown/breaker/trust maps, lazy registration and get_mcp_status all read
and write through the composite key; teardown resolves a task's key by
identity (the MCP loop has no profile context). The toolset memo key includes
registry.current_scope_key().

An owner's scoped /reload-mcp tore down its connection and, with it, every
adopting profile's tool overlay; nothing re-ran the adopters' discovery until
they reloaded. shutdown_mcp_servers(scope=) now records the orphaned adopters
and register_mcp_servers re-registers them under their own home + secret scope
once the owner's rediscovery pass completes.

Docs: multi-profile-gateways.md states the per-profile connection rule.

Fixes #106005
Fixes #91654
Co-authored-by: Bergmann89 <info@bergmann89.de>
Co-authored-by: Izzy-Gottz <srulynj@gmail.com>
2026-09-11 15:27:23 -07:00

64 lines
2.9 KiB
Python

"""Connection-ledger keys for tools.mcp_tool under a profile multiplexer.
Every ledger in ``tools.mcp_tool`` (``_servers``, connecting/error/cooldown maps, circuit
breaker, lazy configs, trust metadata) is keyed by a *connection key*: the bare server name
outside a multiplexer (single-profile processes are unchanged, byte for byte), and
``(owner_scope, name)`` under one. Two profiles that both configure ``github`` with their own
token are two connections; keying by name alone let the first profile's connection shadow the
second's forever — its ``register_mcp_servers`` saw the name as "already connected", adopted
nothing (different credentials) and left the profile silently tool-less (#106005, #91654).
A profile may still *adopt* another profile's live connection when the route and credentials
match (``mcp_tool_registration._same_server_route``); ``_server_tool_scopes[key]`` records every
scope that has done so, and ``_resolve_server_key`` finds that shared connection for a caller
whose own scope has none.
"""
from __future__ import annotations
from typing import Optional, Tuple, Union
from tools.mcp_tool_common import _core
ServerKey = Union[str, Tuple[str, str]]
def _server_key(name: str, scope: Optional[str] = None, *, current: bool = True) -> ServerKey:
"""Connection key for *name* owned by *scope* (the current registry scope when *current*).
``None`` scope (no multiplexer) keeps the bare name."""
if scope is None and current:
scope = _core._mcp_registry_scope()
return name if scope is None else (scope, name)
def _key_name(key: ServerKey) -> str:
return key[1] if isinstance(key, tuple) else key
def _key_scope(key: ServerKey) -> Optional[str]:
"""Owning registry scope encoded in *key* (None for a bare, unscoped key)."""
return key[0] if isinstance(key, tuple) else None
def _key_visible_in_scope(key: ServerKey, scope: Optional[str]) -> bool:
"""Whether the connection under *key* serves *scope*: owned by it or adopted into it.
Caller holds ``_core._lock`` or tolerates a racy read (status surfaces)."""
if scope is None:
return True
return _key_scope(key) == scope or scope in _core._server_tool_scopes.get(key, ())
def _resolve_server_key(name: str, scope: Optional[str] = None, *, current: bool = True) -> ServerKey:
"""The connection key a call to *name* from *scope* must use: the scope's own connection
(live, connecting or lazily registered) when it has one, else a shared connection it
adopted, else its own (not yet existing) key so bookkeeping lands under this scope."""
if scope is None and current:
scope = _core._mcp_registry_scope()
own = _server_key(name, scope, current=False)
if scope is None or own in _core._servers or own in _core._lazy_server_configs:
return own
for key, scopes in _core._server_tool_scopes.items():
if scope in scopes and _key_name(key) == name and key in _core._servers:
return key
return own