fix(mcp): scope MCP status, inventory and /reload-mcp to the request profile

Two profiles configuring a same-named MCP server shared one connection and
the MCP settings panel showed another profile's runtime ("Active, N tools"
next to an empty inventory); /reload-mcp stopped every profile's servers and
rediscovered from the process-default config.

- api.profiles._set_hermes_home() is the single writer of the process-profile
  home: it records get_process_profile_home() and hands it to Hermes Agent's
  hermes_constants.pin_process_hermes_home when available, at startup and on
  switch_profile(process_wide=True), so the per-turn HERMES_HOME mirror no
  longer makes every turn look like the process profile (bare-name,
  cross-profile MCP keys) and the anchor cannot drift from HERMES_HOME.
- Run get_mcp_status/registry reads and /reload-mcp inside
  api.mcp_runtime.mcp_runtime_scope(): request profile's Hermes-home override
  and secret scope (root included), no os.environ mutation, restored on exit.
- One ledger predicate, ledger_key_serves_view() (owned via
  _server_scope_keys/key scope, or adopted via _server_tool_scopes), is shared
  by status, inventory and the reload summary. The Agent's launch-profile view
  is process-wide and name-indexed, so filter_runtime_status_to_view()
  rebuilds connected/tools/status/error from the entries serving the profile:
  the root profile never shows a routed profile's connection (nor lets its own
  parked failed-spawn task borrow a routed profile's live same-named one).
- Inventory lists only tools in the profile's own registry slot for servers it
  configures; responses carry runtime_scope.
- Fail closed when the agent's routing does not match the resolved profile:
  hide runtime data (runtime_scope "unavailable", shown as a notice in the MCP
  panel, 14 locales) and refuse /reload-mcp.
- /reload-mcp shuts down only the profile's own ledger names (parked
  included), drops its own connect cooldown/error entries so a failed spawn is
  retried (the wildcard cleared every cooldown; a scoped shutdown only clears
  the live keys it tears down), rediscovers under the profile's home and
  counts live connections only. The bare wildcard remains for agents without
  profile-scoped MCP.
- Signature introspection is centralized (accepts_keywords) and falls back to
  the bare call instead of blanking every status; ledger key helpers resolve
  to the Agent's tools.mcp_tool_scope when present; tools.mcp_tool is
  resolved by full name so stubbed agents still work.
- ARCHITECTURE.md §4.10 and docs/troubleshooting.md document the boundary.
- Tests: agent-free contract (models adoption, parked tasks, cooldowns and the
  process-wide root view) and real-Agent integration (fake stdio MCP server,
  skips without hermes-agent + mcp SDK).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tancouandClaude Opus 5 committed 2026-09-24 09:53:02 +02:00
1 parent 2d1e6f812a
commit 90b814f673
12 files changed
+1628 -58

No files matched your search

+65 -11
View File
@@ -257,11 +257,28 @@ def _run_codex_runtime_command(arg_string: str) -> str:
def _run_reload_mcp_command() -> str:
"""Execute the MCP reconnect path and return a short user-facing summary."""
"""Execute the MCP reconnect path and return a short user-facing summary.
Scoped to the request profile: only that profile's connections are shut
down and rediscovered from that profile's config, so other profiles'
same-named servers keep running (agents with profile-scoped MCP; older
agents keep their process-wide reload).
"""
with _RELOAD_MCP_LOCK:
try:
import importlib
from tools.mcp_tool import _servers, _lock
# Resolved by full name (like the ``from`` import above) so a stubbed
# ``tools.mcp_tool`` without a ``tools`` package still works.
_core = importlib.import_module("tools.mcp_tool")
from api.agent_compat import agent_attr
from api.mcp_runtime import (
accepts_keywords,
clear_profile_connect_cooldowns,
ledger_key_serves_view,
mcp_key_name,
mcp_runtime_scope,
)
shutdown_mcp_servers = agent_attr(
"tools.mcp_tool", "shutdown_mcp_servers", "tools.mcp_tool_lifecycle"
@@ -273,18 +290,55 @@ def _run_reload_mcp_command() -> str:
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
raise RuntimeError("MCP runtime unavailable") from exc
try:
with _lock:
old_servers = set(_servers.keys())
with mcp_runtime_scope("/reload-mcp") as view:
if not view.trusted:
# Resetting under an unconfirmed scope could stop another profile's
# connection or rebuild this one under the wrong owner.
raise RuntimeError(
"MCP reload unavailable: this profile's MCP runtime scope could not be "
"confirmed (a chat turn on this profile may be running); retry when it finishes"
)
try:
# Profile-scoped teardown needs both the scoped ledger and a
# shutdown that takes scope/names; otherwise keep the legacy reload.
legacy = view.legacy or not accepts_keywords(shutdown_mcp_servers, "scope", "names")
shutdown_mcp_servers()
new_tools = discover_mcp_tools()
def live_names() -> set:
# Same criteria as the status page: the connection serves this
# profile (owned or adopted) and is live. A parked task (failed
# spawn retained for retry) has no session and is not "connected".
return {
mcp_key_name(key) for key, server in _servers.items()
if (legacy or ledger_key_serves_view(_core, key, view))
and getattr(server, "session", True) is not None
}
with _lock:
connected_servers = set(_servers.keys())
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
with _lock:
old_servers = live_names()
# Every ledger entry serving this profile, parked ones included:
# a parked task left in place would make discovery skip the server.
owned_names = {
mcp_key_name(key) for key in _servers
if ledger_key_serves_view(_core, key, view)
}
if legacy:
shutdown_mcp_servers()
else:
# ``names`` is required: ``scope=None`` alone is the process-wide
# wildcard. Only names with a ledger key are passed; shutdown
# ignores anything else, and a key of another owner is never selected.
shutdown_mcp_servers(scope=view.registry_scope, names=owned_names)
# The wildcard also dropped connect backoff; keep that for this
# profile's own failed servers so the reload retries them now.
clear_profile_connect_cooldowns(_core, view)
new_tools = discover_mcp_tools()
with _lock:
connected_servers = live_names()
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
added = connected_servers - old_servers
removed = old_servers - connected_servers
+325
View File
@@ -0,0 +1,325 @@
"""Profile-bound view of Hermes Agent's in-process MCP runtime.
The MCP status page, the tool inventory and ``/reload-mcp`` read or reset
Hermes Agent's MCP ledger from the HTTP request thread. Hermes Agent scopes
that ledger per profile (connection key ``(profile_home_key, name)``) only
when the calling task serves a *routed* profile, i.e. its context-local
Hermes-home override differs from the process home. These helpers bind the
request profile's home (without touching ``os.environ``) and check that the
agent's routing decision matches the profile WebUI resolved, so a read never
shows another profile's connection and a reload never resets one.
Ledger helpers below take ``core`` (``tools.mcp_tool``) explicitly and read
its maps with defaults, so an agent that lacks a map degrades to "unscoped"
rather than raising inside a status read.
"""
from __future__ import annotations
import importlib
import inspect
import logging
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Generator, Iterable, Optional
logger = logging.getLogger(__name__)
# Scope label reported by the MCP endpoints.
SCOPE_PROFILE = "profile" # runtime view bound to the request profile
SCOPE_LEGACY = "legacy_process" # agent predates profile-scoped MCP; process-wide view
SCOPE_UNAVAILABLE = "unavailable" # profile scope could not be confirmed; runtime hidden
@dataclass(frozen=True)
class McpRuntimeView:
"""How the current context sees Hermes Agent's MCP runtime.
``trusted``: runtime reads/writes in this context belong to ``profile_home``.
``legacy``: the agent has no profile-scoped MCP ledger (process-wide by name).
``registry_scope``: the tool-registry slot owning this profile's MCP tools
(the profile overlay key when routed, ``None`` for the process profile).
"""
profile_home: Path
trusted: bool
legacy: bool
registry_scope: Optional[str] = None
@property
def scope_label(self) -> str:
if not self.trusted:
return SCOPE_UNAVAILABLE
return SCOPE_LEGACY if self.legacy else SCOPE_PROFILE
def _routing_view(profile_home: Path, override_bound: bool) -> McpRuntimeView:
try:
from agent.secret_scope import serves_routed_profile
from hermes_constants import hermes_home_key
from tools.registry import registry
except ImportError:
return McpRuntimeView(profile_home, trusted=True, legacy=True)
from api.profiles import get_process_profile_home
try:
expected_routed = hermes_home_key(profile_home) != hermes_home_key(get_process_profile_home())
# Without the override a routed profile cannot be served; with it, the agent must
# agree. It disagrees when a streaming turn has mirrored this profile's home into
# os.environ['HERMES_HOME'] on an agent without a pinned process home.
if expected_routed and not override_bound:
return McpRuntimeView(profile_home, trusted=False, legacy=False)
if bool(serves_routed_profile()) != expected_routed:
return McpRuntimeView(profile_home, trusted=False, legacy=False)
scope = registry.current_scope_key() if expected_routed else None
except Exception:
logger.debug("Failed to resolve MCP runtime scope for %s", profile_home, exc_info=True)
return McpRuntimeView(profile_home, trusted=False, legacy=False)
return McpRuntimeView(profile_home, trusted=True, legacy=False, registry_scope=scope)
@contextmanager
def mcp_runtime_scope(purpose: str) -> Generator[McpRuntimeView, None, None]:
"""Bind the active request profile for Hermes Agent MCP runtime calls.
Installs the context-local Hermes-home override and secret scope for the
request profile (including the root profile) and restores them on exit,
including on exceptions. Never mutates ``os.environ``, starts, or probes
MCP servers.
"""
from api.profiles import get_active_hermes_home, profile_env_for_active_request_readonly
profile_home = Path(get_active_hermes_home())
with profile_env_for_active_request_readonly(
purpose, logger_override=logger, include_root=True
) as override_bound:
yield _routing_view(profile_home, bool(override_bound))
def accepts_keywords(fn: Callable, *names: str) -> bool:
"""True when *fn*'s signature exposes every keyword in *names*.
Introspection failures (builtins, C callables, exotic wrappers) count as
"not supported" so callers fall back to the bare call instead of dropping
the whole read.
"""
try:
params = inspect.signature(fn).parameters
except (TypeError, ValueError):
return False
return all(name in params for name in names)
# ── Ledger keys ──────────────────────────────────────────────────────────────
# Hermes Agent's own helpers (``tools.mcp_tool_scope``) are preferred so the key
# shape has one owner; the lambdas only cover agents that predate that module.
def _key_helper(name: str, fallback: Callable) -> Callable:
try:
# Full-name import: works with a stubbed module and no ``tools`` package.
mcp_tool_scope = importlib.import_module("tools.mcp_tool_scope")
except Exception:
return fallback
helper = getattr(mcp_tool_scope, name, None)
return helper if callable(helper) else fallback
def mcp_key_name(key: Any) -> str:
"""Server name of an MCP connection-ledger key (bare name or ``(scope, name)``)."""
return str(_key_helper("_key_name", lambda k: k[1] if isinstance(k, tuple) else k)(key))
def mcp_key_scope(key: Any) -> Optional[str]:
"""Owning registry scope encoded in a ledger key (``None`` for a bare key)."""
return _key_helper("_key_scope", lambda k: k[0] if isinstance(k, tuple) else None)(key)
def ledger_key_owner(core: Any, key: Any) -> Optional[str]:
"""Scope owning the connection under *key*: recorded at adoption, else the key's own."""
owners = getattr(core, "_server_scope_keys", None)
if isinstance(owners, dict) and key in owners:
return owners[key]
return mcp_key_scope(key)
def ledger_key_serves_view(core: Any, key: Any, view: McpRuntimeView) -> bool:
"""True when the connection under *key* serves the profile in *view*.
Owned by the view's registry scope, or adopted into it (a routed profile
sharing an identical connection; ``_server_tool_scopes``). Hermes Agent's
own launch-profile view (scope ``None``) is process-wide, so this predicate
is what narrows the root profile to its unscoped connections. Legacy agents
have one process-wide ledger, so every key counts.
"""
if view.legacy:
return True
if ledger_key_owner(core, key) == view.registry_scope:
return True
if view.registry_scope is None:
return False
adopters = getattr(core, "_server_tool_scopes", None)
if not isinstance(adopters, dict):
return False
return view.registry_scope in (adopters.get(key) or ())
@dataclass(frozen=True)
class ServedRuntime:
"""Runtime state of one server name as seen from a profile's ledger keys."""
live_tools: Optional[int] = None # tool count of a live (session-bearing) connection
connecting: bool = False
error: Optional[str] = None
lazy_tools: Optional[int] = None
@property
def live(self) -> bool:
return self.live_tools is not None
def _served_keys(core: Any, ledger: str, view: McpRuntimeView) -> list:
keys = getattr(core, ledger, None)
if not isinstance(keys, (dict, set, frozenset, list, tuple)):
return []
return [key for key in list(keys) if ledger_key_serves_view(core, key, view)]
def view_served_runtime(core: Any, view: McpRuntimeView) -> dict[str, ServedRuntime]:
"""Per-name runtime state of the ledger entries serving *view*.
Caller holds ``core._lock`` or tolerates a racy read (status surfaces).
"""
live: dict[str, int] = {}
connecting: set[str] = set()
errors: dict[str, str] = {}
lazy: dict[str, int] = {}
servers = getattr(core, "_servers", None)
if isinstance(servers, dict):
for key in _served_keys(core, "_servers", view):
server = servers.get(key)
if server is None or getattr(server, "session", None) is None:
continue # parked / mid-connect task: retained, not connected
names = getattr(server, "_registered_tool_names", None)
if not isinstance(names, (list, tuple, set)):
names = getattr(server, "_tools", None) or ()
live[mcp_key_name(key)] = len(names)
for key in _served_keys(core, "_server_connecting", view):
connecting.add(mcp_key_name(key))
error_map = getattr(core, "_server_connect_errors", None)
if isinstance(error_map, dict):
for key in _served_keys(core, "_server_connect_errors", view):
errors[mcp_key_name(key)] = str(error_map.get(key, ""))
lazy_tools = getattr(core, "_lazy_server_tool_names", None)
for key in _served_keys(core, "_lazy_server_configs", view):
count = lazy_tools.get(key) if isinstance(lazy_tools, dict) else None
lazy[mcp_key_name(key)] = len(count) if isinstance(count, (list, tuple, set)) else 0
out: dict[str, ServedRuntime] = {}
for name in set(live) | connecting | set(errors) | set(lazy):
out[name] = ServedRuntime(
live_tools=live.get(name),
connecting=name in connecting,
error=errors.get(name),
lazy_tools=lazy.get(name),
)
return out
_RUNTIME_ROW_FIELDS = ("connected", "tools", "status", "error", "sampling", "tool_schemas")
def filter_runtime_status_to_view(statuses: Iterable[Any], view: McpRuntimeView) -> list:
"""Rebuild the runtime fields of ``get_mcp_status()`` rows from the keys serving *view*.
Under a routed scope the agent already filters by owner/adopter; under the
launch profile (scope ``None``) it reports every profile's connection and
indexes them by bare name, so the root profile would show "Active, N tools"
for a routed profile's server it never connected (or its own parked server
as connected because another profile's same-named one is live). Rows keep
their config-derived fields (name, transport, disabled) and get
``connected`` / ``tools`` / ``status`` / ``error`` from this profile's own
ledger entries only.
"""
rows = [row for row in statuses if isinstance(row, dict)]
if view.legacy or not rows:
return rows
try:
core = importlib.import_module("tools.mcp_tool")
except Exception:
return rows
lock = getattr(core, "_lock", None)
try:
if lock is not None:
with lock:
served = view_served_runtime(core, view)
else:
served = view_served_runtime(core, view)
except Exception:
logger.debug("Failed to read MCP ledger ownership for %s", view.profile_home, exc_info=True)
return []
out = []
for row in rows:
rebuilt = {k: v for k, v in row.items() if k not in _RUNTIME_ROW_FIELDS}
state = served.get(str(row.get("name")))
disabled = bool(row.get("disabled")) or row.get("status") == "disabled"
rebuilt["connected"] = bool(state and state.live)
rebuilt["tools"] = state.live_tools if state and state.live else (
state.lazy_tools if state and state.lazy_tools is not None and not disabled else 0
)
if state and state.live:
rebuilt["status"] = "connected"
elif disabled:
rebuilt["status"] = "disabled"
elif state and state.connecting:
rebuilt["status"] = "connecting"
elif state and state.error is not None:
rebuilt["status"] = "failed"
rebuilt["error"] = state.error
elif state and state.lazy_tools is not None:
rebuilt["status"] = "lazy"
else:
rebuilt["status"] = "configured"
out.append(rebuilt)
return out
_COOLDOWN_LEDGERS = ("_server_connect_retry_after", "_server_connect_failures", "_server_connect_errors")
def clear_profile_connect_cooldowns(core: Any, view: McpRuntimeView) -> None:
"""Forget connect backoff/error state of the servers *view*'s profile owns.
A server that failed to spawn never reaches ``_servers``, so a scoped
``shutdown_mcp_servers(scope=..., names=...)`` (which clears cooldowns for
the live keys it tears down) leaves its backoff in place and the following
``discover_mcp_tools()`` skips it. The process-wide wildcard cleared every
cooldown; this keeps ``/reload-mcp`` a retry for the profile's own servers
without touching another owner's backoff. Owned keys only, never adopted ones.
"""
lock = getattr(core, "_lock", None)
if lock is None:
return
with lock:
for ledger in _COOLDOWN_LEDGERS:
entries = getattr(core, ledger, None)
if not isinstance(entries, dict):
continue
for key in [k for k in entries if ledger_key_owner(core, k) == view.registry_scope]:
entries.pop(key, None)
def registry_tool_owned_by_view(registry, tool_name: str, view: McpRuntimeView) -> bool:
"""True when *tool_name* is registered in this profile's own registry slot.
The merged registry view overlays a profile's tools on the global slot, so a
routed profile would otherwise also list the process profile's MCP tools.
"""
if view.legacy:
return True
snapshot = getattr(registry, "snapshot_registration", None)
if not callable(snapshot):
return True
try:
return snapshot(tool_name, scope=view.registry_scope) is not None
except Exception:
return False
+61 -7
View File
@@ -56,6 +56,11 @@ _loaded_profile_env_keys: set[str] = set()
# process-global _active_profile.
_tls = threading.local()
# Home of the profile this process serves as its own (set by init_profile_state).
# Streaming turns mirror their profile into os.environ['HERMES_HOME'], so the live
# env var is not a stable identity; see _pin_process_profile_home().
_PROCESS_PROFILE_HOME: Optional[str] = None
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock()
@@ -1360,6 +1365,8 @@ def profile_env_for_background_worker(
def profile_env_for_active_request_readonly(
purpose: str = "provider/model read",
logger_override: Optional[logging.Logger] = None,
*,
include_root: bool = False,
):
"""Apply the active per-request profile's env to thread-local state only (#3957).
@@ -1376,11 +1383,18 @@ def profile_env_for_active_request_readonly(
process-global ``os.environ``.
No-ops for the default/root profile, which is the common single-profile
deployment case.
deployment case, unless ``include_root`` is set: callers whose Hermes Agent
reads must not follow a streaming turn's mirrored ``HERMES_HOME`` (MCP
runtime status/reload) bind the root profile's home explicitly too.
Yields True when the context-local Hermes-home override is installed for
the resolved profile, else False (no-op, older agent, or resolution error).
"""
profile = (get_active_profile_name() or "").strip()
if not profile or _is_root_profile(profile):
yield
if include_root and not profile:
profile = "default"
if not profile or (_is_root_profile(profile) and not include_root):
yield False
return
try:
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
@@ -1396,7 +1410,7 @@ def profile_env_for_active_request_readonly(
purpose,
exc_info=True,
)
yield
yield False
return
try:
from hermes_constants import (
@@ -1440,7 +1454,7 @@ def profile_env_for_active_request_readonly(
purpose,
exc_info=True,
)
yield
yield home_override_installed
finally:
if _has_scope and _secret_scope_mod is not None:
try:
@@ -1529,8 +1543,14 @@ def profile_scope_for_detached_worker(
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
"""Set HERMES_HOME env var and monkey-patch cached module-level paths.
Every process-wide home change (startup, ``switch_profile(process_wide=True)``)
goes through here, so the process-profile pin used for MCP routing decisions
is updated in the same step and cannot drift from ``HERMES_HOME``.
"""
os.environ['HERMES_HOME'] = str(home)
_pin_process_profile_home(home)
patch_skill_home_modules(home)
@@ -1610,11 +1630,45 @@ def init_profile_state() -> None:
else:
_active_profile = _read_active_profile_file()
home = get_active_hermes_home()
_set_hermes_home(home)
_set_hermes_home(home) # also pins the process-profile home (MCP routing anchor)
install_cron_scheduler_profile_isolation()
_reload_dotenv(home)
def _pin_process_profile_home(home: Path) -> None:
"""Record the profile home this process serves as its own, for WebUI and Hermes Agent.
Called from ``_set_hermes_home()`` so startup and process-wide profile switches
keep one owner for the value. Hermes Agent keys MCP connections and registry
overlays by profile only when a task serves a *routed* profile: the context-local
Hermes-home override differs from the process home. Streaming turns mirror their
profile into ``os.environ['HERMES_HOME']`` for legacy readers, which makes every
turn's own profile look like the process profile, so same-named MCP servers of
different profiles share one bare-name connection. Agents exposing
``hermes_constants.pin_process_hermes_home`` take a stable anchor instead; older
agents keep following the env var, and ``get_process_profile_home()`` still gives
WebUI the anchor to detect that skew.
"""
global _PROCESS_PROFILE_HOME
_PROCESS_PROFILE_HOME = str(home)
try:
import hermes_constants
pin = getattr(hermes_constants, 'pin_process_hermes_home', None)
if callable(pin):
pin(str(home))
except Exception:
logger.debug("Hermes Agent process-home pin unavailable", exc_info=True)
def get_process_profile_home() -> Path:
"""Return the profile home this WebUI process serves as its own (stable per process)."""
if _PROCESS_PROFILE_HOME:
return Path(_PROCESS_PROFILE_HOME)
if _INITIAL_HERMES_HOME:
return Path(_INITIAL_HERMES_HOME).expanduser()
return _DEFAULT_HERMES_HOME
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
"""Switch the active profile.
+73 -30
View File
@@ -29346,17 +29346,34 @@ def _parse_mcp_enabled(value) -> bool:
return True
def _mcp_runtime_status_by_name() -> dict[str, dict]:
def _mcp_runtime_status_by_name(servers=None, view=None) -> dict[str, dict]:
"""Return already-known MCP runtime status without starting servers.
``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and
configuration; it does not probe or spawn MCP subprocesses. If Hermes Agent
is unavailable, fall back to an empty map so the API remains safe.
Call it inside ``mcp_runtime_scope()`` and pass its ``view``: the agent
filters a routed profile's connections itself, but its launch-profile view
is process-wide, so rows are narrowed to the connections serving ``view``
(``api.mcp_runtime.filter_runtime_status_to_view``). ``servers`` (the
profile's ``mcp_servers`` WebUI displays) is passed as ``configured`` when
supported so both read the same config.
"""
try:
from api.agent_compat import agent_attr
from api.mcp_runtime import accepts_keywords, filter_runtime_status_to_view
get_mcp_status = agent_attr("tools.mcp_tool", "get_mcp_status", "tools.mcp_tool_discovery")
statuses = get_mcp_status()
if isinstance(servers, dict) and accepts_keywords(get_mcp_status, "configured"):
# Invalid entries are summarized as invalid_config by WebUI; keep them
# out of the agent call so one bad entry cannot blank every status.
statuses = get_mcp_status(configured={
str(name): scfg for name, scfg in servers.items() if isinstance(scfg, dict)
})
else:
statuses = get_mcp_status()
if view is not None and isinstance(statuses, list):
statuses = filter_runtime_status_to_view(statuses, view)
except Exception:
return {}
if not isinstance(statuses, list):
@@ -29539,10 +29556,15 @@ def _mcp_tools_from_runtime_status(runtime_by_name, server_summaries):
return tools
def _mcp_tools_from_registry(server_summaries):
"""Read already-registered MCP tool schemas without probing MCP servers."""
def _mcp_tools_from_registry(server_summaries, view=None):
"""Read already-registered MCP tool schemas without probing MCP servers.
With a profile ``view`` (see ``api.mcp_runtime``), only tools of servers the
profile configures, registered in that profile's own registry slot, are listed.
"""
try:
from tools.registry import registry
from api.mcp_runtime import registry_tool_owned_by_view
except Exception:
return []
tools = []
@@ -29558,6 +29580,11 @@ def _mcp_tools_from_registry(server_summaries):
if not isinstance(toolset, str) or not toolset.startswith("mcp-"):
continue
server_name = toolset[len("mcp-"):]
if view is not None and not view.legacy and (
server_name not in server_summaries
or not registry_tool_owned_by_view(registry, tool_name, view)
):
continue
schema = registry.get_schema(tool_name) or {}
server_summary = server_summaries.get(server_name, {
"name": server_name,
@@ -29569,22 +29596,45 @@ def _mcp_tools_from_registry(server_summaries):
return tools
def _mcp_profile_runtime_inventory(servers, purpose, *, include_tools=True):
"""Build server summaries and tools from ONE runtime view of the request profile.
Status, tool count and inventory are all read inside the same
``mcp_runtime_scope()`` so they describe the same profile's connections.
When that profile scope cannot be confirmed, runtime data is withheld rather
than showing another profile's connection. Passive: never starts or probes
MCP servers.
"""
from api.mcp_runtime import mcp_runtime_scope
runtime = {}
tools = []
source = "none"
with mcp_runtime_scope(purpose) as view:
if view.trusted:
runtime = _mcp_runtime_status_by_name(servers, view)
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
if include_tools and view.trusted:
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries, view)
source = "tool_registry" if tools else "none"
return server_summaries, tools, source, view.scope_label
def _handle_mcp_tools_list(handler):
"""List known MCP tools from already-available runtime inventory only."""
cfg = get_config_for_profile_home(get_active_hermes_home())
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries)
source = "tool_registry" if tools else "none"
server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/mcp/tools"
)
tools.sort(key=lambda row: (row.get("server", ""), row.get("name", "")))
unavailable_servers = [
summary["name"] for summary in server_summaries.values()
@@ -29595,6 +29645,7 @@ def _handle_mcp_tools_list(handler):
"total": len(tools),
"source": source,
"inventory_scope": "already_known_runtime_only",
"runtime_scope": runtime_scope,
"unavailable_servers": unavailable_servers,
})
@@ -29784,16 +29835,9 @@ def _handle_notes_sources_list(handler):
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries)
source = "tool_registry" if tools else "none"
server_summaries, tools, source, _runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/notes/sources"
)
return j(handler, {
"enabled": True,
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
@@ -30089,15 +30133,14 @@ def _handle_mcp_servers_list(handler):
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
result = [
_server_summary(name, scfg, runtime.get(str(name)))
for name, scfg in servers.items()
]
server_summaries, _tools, _source, runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/mcp/servers", include_tools=False
)
return j(handler, {
"servers": result,
"servers": list(server_summaries.values()),
"toggle_supported": True,
"reload_required": True,
"runtime_scope": runtime_scope,
})
+5 -8
View File
@@ -10271,14 +10271,11 @@ def _run_agent_streaming(
# loaded the default profile's `mcp_servers`, even when the session
# was stamped with a non-default profile. See issue #1968.
#
# NOTE: `_servers` in `tools/mcp_tool.py` is a process-global registry
# keyed by server name. This means once profile A registers a server
# named e.g. `postgres`, profile B's discovery sees it as already
# connected and skips it — even if B's config points at a different
# binary. Fully fixing multi-profile concurrent use requires keying
# `_servers` by `(profile_home, name)` upstream in hermes-agent; that
# lives outside this WebUI repo. This change fixes the headline bug
# for users who run a single non-default profile per WebUI process.
# NOTE: hermes-agent keys MCP connections by `(profile_home, name)` only
# for a routed profile (override != process home). The HERMES_HOME
# mirror above would make this turn's own profile look like the process
# profile, falling back to bare, cross-profile names, unless the agent
# honours the pin from `api.profiles._pin_process_profile_home()`.
try:
from api.agent_compat import agent_attr
discover_mcp_tools = agent_attr("tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery")