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:
1 parent
2d1e6f812a
commit
90b814f673
12 files changed
+1628
-58
No files matched your search
+65
-11
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user