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
+1620
-50
No files matched your search
@@ -475,6 +475,52 @@ pointer-removed cases to `tests/test_agent_compat.py`. The resolver is
|
||||
compatibility-only: delete it, and import directly from the new homes, once the WebUI
|
||||
stops supporting Agents that predate the split.
|
||||
|
||||
### 4.10 MCP Runtime Profile Boundary
|
||||
|
||||
Hermes Agent owns the in-process MCP ledger. It keys a connection by
|
||||
`(profile_home_key, name)` and registers its tools in that profile's registry overlay
|
||||
only when the calling task serves a *routed* profile: the context-local Hermes-home
|
||||
override differs from the process home. Otherwise the connection uses the bare server
|
||||
name and the global registry slot, which belong to the process profile.
|
||||
|
||||
- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:
|
||||
startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go
|
||||
through it, so `get_process_profile_home()` and the Agent pin
|
||||
(`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated
|
||||
in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into
|
||||
`os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a
|
||||
turn's own profile look like the process profile, so same-named servers of different
|
||||
profiles share one bare-name connection.
|
||||
- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run
|
||||
their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the
|
||||
request profile's home and secret scope (root profile included) without touching
|
||||
`os.environ`. Status, tool count and inventory are read from one scope; the inventory
|
||||
lists only tools in that profile's own registry slot for servers it configures.
|
||||
- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else
|
||||
the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared
|
||||
connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for
|
||||
status, inventory and the reload summary. The Agent's launch-profile view (scope
|
||||
`None`) is process-wide, so `filter_runtime_status_to_view()` downgrades
|
||||
`get_mcp_status()` rows of servers the root profile does not serve to `configured`
|
||||
rather than showing a routed profile's connection, tool count or connect error.
|
||||
- The scope is trusted only when the Agent's routing decision matches the profile WebUI
|
||||
resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an
|
||||
Agent without the pin), status and inventory withhold runtime data
|
||||
(`runtime_scope: "unavailable"`, shown as a notice in the MCP panel) and
|
||||
`/reload-mcp` refuses instead of resetting another owner's connection.
|
||||
- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's
|
||||
own scope and the names of its live connections; `scope=None` without `names` is the
|
||||
process-wide wildcard and is used only with Agents that predate profile-scoped MCP
|
||||
(`runtime_scope: "legacy_process"`). A scoped shutdown only clears connect backoff for
|
||||
the live keys it tears down, so WebUI also drops the profile's own cooldown/error
|
||||
entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed
|
||||
to spawn is retried by the reload, as the wildcard did, without touching another
|
||||
owner's backoff.
|
||||
|
||||
Status and inventory stay passive: they never start or probe an MCP server. Ledger key
|
||||
helpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape
|
||||
has one owner; the local fallbacks only cover Agents that predate that module.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Architecture: Current State
|
||||
|
||||
+58
-4
@@ -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,15 +290,52 @@ 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
|
||||
|
||||
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:
|
||||
with _lock:
|
||||
old_servers = set(_servers.keys())
|
||||
# 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")
|
||||
|
||||
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:
|
||||
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 = set(_servers.keys())
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+72
-29
@@ -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")
|
||||
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")
|
||||
|
||||
@@ -309,6 +309,27 @@ Interpret the two together:
|
||||
|
||||
---
|
||||
|
||||
## MCP panel shows another profile's servers, or "Live status for this profile is unavailable"
|
||||
|
||||
**Symptom.** With several profiles, the MCP settings panel of profile A shows a server as *Active* with a tool count while the tool inventory is empty (or lists profile B's tools); `/reload-mcp` on one profile stops the other profile's servers; or the panel shows the notice *"Live status for this profile is unavailable right now"* and `/reload-mcp` answers *"MCP runtime scope could not be confirmed"*.
|
||||
|
||||
**Why.** Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and `/reload-mcp` to the request profile (`ARCHITECTURE.md` §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into `HERMES_HOME`; Agents that predate `hermes_constants.pin_process_hermes_home` cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection.
|
||||
|
||||
**Diagnostic commands.**
|
||||
|
||||
```bash
|
||||
# runtime_scope: "profile" (bound), "legacy_process" (Agent without profile-scoped MCP),
|
||||
# "unavailable" (scope could not be confirmed right now)
|
||||
curl -s -b "hermes_profile=<profile>" http://127.0.0.1:8787/api/mcp/servers | python3 -m json.tool | grep -E '"(name|status|tool_count|runtime_scope)"'
|
||||
python3 -c "import hermes_constants; print(hasattr(hermes_constants, 'pin_process_hermes_home'))"
|
||||
```
|
||||
|
||||
**Fix.** `unavailable` while a turn is running is expected: refresh once the turn finishes. If it persists with no turn running, the Agent predates the process-home pin; upgrade Hermes Agent. `legacy_process` means the Agent has no profile-scoped MCP ledger at all; its `/reload-mcp` stays process-wide by design. After changing a profile's `mcp_servers`, run `/reload-mcp` **from that profile**: it only resets that profile's own connections and retries its failed servers.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if `runtime_scope` is `"profile"` and a server is still reported *Active* with tools you cannot see in the inventory, or if `/reload-mcp` from one profile changes another profile's `tool_count`.
|
||||
|
||||
---
|
||||
|
||||
## Other troubleshooting
|
||||
|
||||
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
|
||||
|
||||
@@ -78,6 +78,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP Servers',
|
||||
mcp_servers_desc: 'View MCP servers configured in config.yaml.',
|
||||
mcp_no_servers: 'No MCP servers configured.',
|
||||
mcp_runtime_scope_unavailable: 'Live status for this profile is unavailable right now (a chat turn may be running). Refresh when it finishes.',
|
||||
mcp_add_server: '+ Add Server',
|
||||
mcp_field_name: 'Server Name',
|
||||
mcp_transport_label: 'Transport Type',
|
||||
@@ -1945,6 +1946,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'Server MCP',
|
||||
mcp_servers_desc: 'Visualizza i server MCP configurati in config.yaml.',
|
||||
mcp_no_servers: 'Nessun server MCP configurato.',
|
||||
mcp_runtime_scope_unavailable: 'Lo stato in tempo reale di questo profilo non è al momento disponibile (potrebbe essere in corso un turno di chat). Aggiorna quando sarà terminato.',
|
||||
mcp_add_server: '+ Aggiungi Server',
|
||||
mcp_field_name: 'Nome Server',
|
||||
mcp_transport_label: 'Tipo Trasporto',
|
||||
@@ -3750,6 +3752,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCPサーバー',
|
||||
mcp_servers_desc: 'config.yaml で設定された MCP サーバーを管理します。',
|
||||
mcp_no_servers: 'MCPサーバーは設定されていません。',
|
||||
mcp_runtime_scope_unavailable: 'このプロファイルのライブステータスは現在利用できません(チャットのターンが実行中の可能性があります)。完了後に更新してください。',
|
||||
mcp_add_server: '+ サーバーを追加',
|
||||
mcp_field_name: 'サーバー名',
|
||||
mcp_transport_label: 'トランスポート種別',
|
||||
@@ -5557,6 +5560,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'Серверы MCP',
|
||||
mcp_servers_desc: 'Просмотр серверов MCP, настроенных в config.yaml.',
|
||||
mcp_no_servers: 'Серверы MCP не настроены.',
|
||||
mcp_runtime_scope_unavailable: 'Актуальный статус этого профиля сейчас недоступен (возможно, выполняется ход чата). Обновите, когда он завершится.',
|
||||
mcp_add_server: '+ Добавить сервер',
|
||||
mcp_field_name: 'Имя сервера',
|
||||
mcp_transport_label: 'Тип транспорта',
|
||||
@@ -7329,6 +7333,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP 服务器',
|
||||
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
||||
mcp_no_servers: '未配置 MCP 服务器。',
|
||||
mcp_runtime_scope_unavailable: 'El estado en vivo de este perfil no está disponible ahora mismo (puede haber un turno de chat en curso). Actualiza cuando termine.',
|
||||
mcp_add_server: '+ 添加服务器',
|
||||
mcp_field_name: '服务器名称',
|
||||
mcp_transport_label: '传输类型',
|
||||
@@ -9080,6 +9085,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP-Server',
|
||||
mcp_servers_desc: 'MCP-Server in config.yaml verwalten.',
|
||||
mcp_no_servers: 'Keine MCP-Server konfiguriert.',
|
||||
mcp_runtime_scope_unavailable: 'Der Live-Status für dieses Profil ist derzeit nicht verfügbar (möglicherweise läuft gerade ein Chat-Zug). Aktualisieren Sie, wenn er abgeschlossen ist.',
|
||||
mcp_add_server: '+ Server hinzufügen',
|
||||
mcp_field_name: 'Servername',
|
||||
mcp_transport_label: 'Transporttyp',
|
||||
@@ -10825,6 +10831,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP 服务器',
|
||||
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
||||
mcp_no_servers: '未配置 MCP 服务器。',
|
||||
mcp_runtime_scope_unavailable: '当前无法获取此配置的实时状态(可能有对话回合正在进行)。请在其完成后刷新。',
|
||||
mcp_add_server: '+ 添加服务器',
|
||||
mcp_field_name: '服务器名称',
|
||||
mcp_transport_label: '传输类型',
|
||||
@@ -12580,6 +12587,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP 伺服器',
|
||||
mcp_servers_desc: '檢視 config.yaml 中設定的 MCP 伺服器。',
|
||||
mcp_no_servers: '未設定 MCP 伺服器。',
|
||||
mcp_runtime_scope_unavailable: '目前無法取得此設定檔的即時狀態(可能有對話回合正在進行)。請在其完成後重新整理。',
|
||||
mcp_add_server: '+ 新增伺服器',
|
||||
mcp_field_name: '伺服器名稱',
|
||||
mcp_transport_label: '傳輸類型',
|
||||
@@ -15996,6 +16004,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP Servers',
|
||||
mcp_servers_desc: 'Manage Model Context Protocol servers configured in config.yaml.',
|
||||
mcp_no_servers: 'No MCP servers configured.',
|
||||
mcp_runtime_scope_unavailable: '이 프로필의 실시간 상태를 현재 확인할 수 없습니다(채팅 턴이 진행 중일 수 있음). 완료된 후 새로 고치세요.',
|
||||
mcp_add_server: '+ Add Server',
|
||||
mcp_field_name: 'Server Name',
|
||||
mcp_transport_label: 'Transport Type',
|
||||
@@ -17801,6 +17810,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'Serveurs MCP',
|
||||
mcp_servers_desc: 'Afficher les serveurs MCP configurés dans config.yaml.',
|
||||
mcp_no_servers: 'Aucun serveur MCP configuré.',
|
||||
mcp_runtime_scope_unavailable: 'Le statut en direct de ce profil est indisponible pour le moment (un tour de conversation est peut-être en cours). Actualisez quand il sera terminé.',
|
||||
mcp_add_server: '+ Ajouter un serveur',
|
||||
mcp_field_name: 'Nom du serveur',
|
||||
mcp_transport_label: 'Type de transport',
|
||||
@@ -20909,6 +20919,7 @@ const LOCALES = {
|
||||
mcp_load_failed: 'Načtení serverů MCP se nezdařilo.',
|
||||
mcp_name_required: 'Je vyžadováno jméno serveru.',
|
||||
mcp_no_servers: 'Nejsou nakonfigurovány žádné servery MCP.',
|
||||
mcp_runtime_scope_unavailable: 'Živý stav tohoto profilu není momentálně k dispozici (možná probíhá tah chatu). Obnovte, až skončí.',
|
||||
mcp_restart_hint: 'Změny serveru jsou zatím jen pro čtení. Upravte config.yaml a restartujte Hermes, aby se změny projevily.',
|
||||
mcp_save: 'Uložit',
|
||||
mcp_save_failed: 'Uložení serveru MCP se nezdařilo.',
|
||||
@@ -21350,6 +21361,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'MCP Sunucuları',
|
||||
mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.',
|
||||
mcp_no_servers: 'Hiçbir MCP sunucusu yapılandırılmadı.',
|
||||
mcp_runtime_scope_unavailable: 'Bu profilin canlı durumu şu anda kullanılamıyor (bir sohbet turu çalışıyor olabilir). Bittiğinde yenileyin.',
|
||||
mcp_add_server: '+ Sunucu Ekle',
|
||||
mcp_field_name: 'Sunucu Adı',
|
||||
mcp_transport_label: 'Taşıma Türü',
|
||||
@@ -23148,6 +23160,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'Serwery MCP',
|
||||
mcp_servers_desc: 'Wyświetl serwery MCP skonfigurowane w config.yaml.',
|
||||
mcp_no_servers: 'Brak skonfigurowanych serwerów MCP.',
|
||||
mcp_runtime_scope_unavailable: 'Stan na żywo tego profilu jest obecnie niedostępny (może trwać tura czatu). Odśwież, gdy się zakończy.',
|
||||
mcp_add_server: '+ Dodaj serwer',
|
||||
mcp_field_name: 'Nazwa serwera',
|
||||
mcp_transport_label: 'Typ transportu',
|
||||
@@ -24941,6 +24954,7 @@ const LOCALES = {
|
||||
mcp_servers_title: 'Máy chủ MCP',
|
||||
mcp_servers_desc: 'Xem các máy chủ MCP được cấu hình trong config.yaml.',
|
||||
mcp_no_servers: 'Chưa có máy chủ MCP nào được cấu hình.',
|
||||
mcp_runtime_scope_unavailable: 'Trạng thái trực tiếp của hồ sơ này hiện không khả dụng (có thể một lượt trò chuyện đang chạy). Hãy làm mới khi lượt đó kết thúc.',
|
||||
mcp_add_server: '+ Thêm máy chủ',
|
||||
mcp_field_name: 'Tên máy chủ',
|
||||
mcp_transport_label: 'Loại kết nối',
|
||||
|
||||
+6
-1
@@ -13234,7 +13234,12 @@ function loadMcpServers(){
|
||||
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML=r.servers.map(s=>{
|
||||
// Live status is withheld while the profile's runtime scope cannot be confirmed
|
||||
// (e.g. a chat turn on this profile is running); say so instead of "not connected".
|
||||
const scopeNotice=r.runtime_scope==='unavailable'
|
||||
?`<div class="mcp-runtime-notice" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_runtime_scope_unavailable'))}</div>`
|
||||
:'';
|
||||
list.innerHTML=scopeNotice+r.servers.map(s=>{
|
||||
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
|
||||
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
|
||||
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
|
||||
|
||||
@@ -161,7 +161,7 @@ class TestMcpList:
|
||||
monkeypatch.setenv('HERMES_CONFIG_PATH', str(override_path))
|
||||
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: active_home)
|
||||
monkeypatch.setattr(routes, 'get_active_hermes_home', lambda: active_home)
|
||||
monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda: {})
|
||||
monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda *_args, **_kwargs: {})
|
||||
config.reload_config()
|
||||
|
||||
h = _make_handler()
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"""MCP status, inventory and /reload-mcp follow the request profile.
|
||||
|
||||
Two profiles configure a stdio MCP server with the same name (``atlassian``):
|
||||
one read-only, one with write tools. These tests drive the real Hermes Agent MCP
|
||||
runtime with a local fake MCP server (no network, no credentials) and check
|
||||
that each profile sees and reloads only its own connection.
|
||||
|
||||
Chat turns are simulated the way ``api/streaming.py`` runs them: the session
|
||||
profile's home is mirrored into ``os.environ['HERMES_HOME']`` and installed as
|
||||
the context-local Hermes-home override before ``discover_mcp_tools()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import mcp # noqa: F401
|
||||
import hermes_constants
|
||||
from agent.secret_scope import current_secret_scope, serves_routed_profile # noqa: F401
|
||||
from tools import mcp_tool as mcp_core
|
||||
from tools.mcp_tool_discovery import discover_mcp_tools
|
||||
from tools.mcp_tool_lifecycle import shutdown_mcp_servers
|
||||
except Exception as exc: # pragma: no cover - depends on the installed agent
|
||||
pytest.skip(
|
||||
f"requires hermes-agent with profile-scoped MCP (serves_routed_profile) and the mcp SDK: {exc}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
from api import profiles
|
||||
from api.commands import execute_agent_command
|
||||
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
|
||||
|
||||
requires_process_home_pin = pytest.mark.skipif(
|
||||
not hasattr(hermes_constants, "pin_process_hermes_home"),
|
||||
reason="streaming turns need hermes_constants.pin_process_hermes_home for profile-scoped MCP keys",
|
||||
)
|
||||
|
||||
SCOPE_PROFILE = "profile"
|
||||
SCOPE_UNAVAILABLE = "unavailable"
|
||||
READ_TOOLS = {"mcp__atlassian__jira_get_issue", "mcp__atlassian__confluence_search"}
|
||||
WRITE_TOOL = "mcp__atlassian__jira_create_issue"
|
||||
|
||||
FAKE_SERVER = textwrap.dedent('''
|
||||
import os
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("fake-atlassian")
|
||||
|
||||
@mcp.tool()
|
||||
def jira_get_issue(key: str) -> str:
|
||||
"""Read a Jira issue (fake)."""
|
||||
return key
|
||||
|
||||
@mcp.tool()
|
||||
def confluence_search(query: str) -> str:
|
||||
"""Search Confluence (fake)."""
|
||||
return "[]"
|
||||
|
||||
if os.environ.get("READ_ONLY_MODE", "true").lower() == "false":
|
||||
@mcp.tool()
|
||||
def jira_create_issue(summary: str) -> str:
|
||||
"""Create a Jira issue (fake, write)."""
|
||||
return "created"
|
||||
|
||||
mcp.run()
|
||||
''')
|
||||
|
||||
|
||||
def _server_cfg(script: Path, *, read_only: bool, account: str) -> dict:
|
||||
# Distinct accounts, like distinct Atlassian credentials: identical routes would
|
||||
# legitimately share one connection (hermes-agent adopts matching routes).
|
||||
return {
|
||||
"command": sys.executable,
|
||||
"args": [str(script)],
|
||||
"env": {"READ_ONLY_MODE": "true" if read_only else "false", "FAKE_ACCOUNT": account},
|
||||
"timeout": 30,
|
||||
"connect_timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
def _write_config(home: Path, servers: dict) -> None:
|
||||
import yaml
|
||||
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}), encoding="utf-8")
|
||||
|
||||
|
||||
class Env:
|
||||
def __init__(self, base: Path, script: Path):
|
||||
self.base = base
|
||||
self.script = script
|
||||
|
||||
def home(self, profile: str) -> Path:
|
||||
return self.base if profile == "default" else self.base / "profiles" / profile
|
||||
|
||||
def configure(self, profile: str, **servers) -> None:
|
||||
_write_config(self.home(profile), servers)
|
||||
|
||||
def atlassian(self, profile: str, *, read_only: bool) -> dict:
|
||||
return _server_cfg(self.script, read_only=read_only, account=profile)
|
||||
|
||||
def chat_turn(self, profile: str) -> list[str]:
|
||||
home = str(self.home(profile))
|
||||
old = os.environ.get("HERMES_HOME")
|
||||
os.environ["HERMES_HOME"] = home
|
||||
token = hermes_constants.set_hermes_home_override(home)
|
||||
try:
|
||||
return discover_mcp_tools()
|
||||
finally:
|
||||
hermes_constants.reset_hermes_home_override(token)
|
||||
os.environ["HERMES_HOME"] = old
|
||||
|
||||
def call(self, fn, profile: str) -> dict:
|
||||
profiles.set_request_profile(profile)
|
||||
try:
|
||||
handler = MagicMock()
|
||||
fn(handler)
|
||||
return json.loads(handler.wfile.write.call_args[0][0])
|
||||
finally:
|
||||
profiles.clear_request_profile()
|
||||
|
||||
def servers(self, profile: str) -> dict:
|
||||
payload = self.call(_handle_mcp_servers_list, profile)
|
||||
return {srv["name"]: srv for srv in payload["servers"]} | {"_scope": payload.get("runtime_scope")}
|
||||
|
||||
def tools(self, profile: str) -> dict:
|
||||
return self.call(_handle_mcp_tools_list, profile)
|
||||
|
||||
def reload(self, profile: str) -> str:
|
||||
profiles.set_request_profile(profile)
|
||||
try:
|
||||
return execute_agent_command("/reload-mcp")
|
||||
finally:
|
||||
profiles.clear_request_profile()
|
||||
|
||||
def connection(self, profile: str):
|
||||
"""The live connection object owned by *profile* for ``atlassian``."""
|
||||
key = (hermes_constants.hermes_home_key(self.home(profile)), "atlassian")
|
||||
if profile == "default":
|
||||
key = "atlassian"
|
||||
with mcp_core._lock:
|
||||
return mcp_core._servers.get(key)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_env(tmp_path, monkeypatch):
|
||||
base = tmp_path / "hermes-home"
|
||||
base.mkdir()
|
||||
script = tmp_path / "fake_atlassian.py"
|
||||
script.write_text(FAKE_SERVER, encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(base))
|
||||
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False) # suite-wide config pin
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
|
||||
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
|
||||
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("", "default"))
|
||||
monkeypatch.setattr(profiles, "_PROCESS_PROFILE_HOME", getattr(profiles, "_PROCESS_PROFILE_HOME", None), raising=False)
|
||||
if hasattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME"):
|
||||
monkeypatch.setattr(
|
||||
hermes_constants, "_PINNED_PROCESS_HERMES_HOME", hermes_constants._PINNED_PROCESS_HERMES_HOME
|
||||
)
|
||||
# What init_profile_state() does at startup for a WebUI launched on ``base``.
|
||||
pin = getattr(profiles, "_pin_process_profile_home", None) or getattr(
|
||||
hermes_constants, "pin_process_hermes_home", lambda _home: None
|
||||
)
|
||||
pin(base)
|
||||
shutdown_mcp_servers()
|
||||
|
||||
env = Env(base, script)
|
||||
env.configure("default")
|
||||
env.configure("profile-read", atlassian=env.atlassian("profile-read", read_only=True))
|
||||
env.configure("profile-write", atlassian=env.atlassian("profile-write", read_only=False))
|
||||
yield env
|
||||
shutdown_mcp_servers()
|
||||
|
||||
|
||||
def _tool_names(payload: dict) -> set[str]:
|
||||
return {tool["name"] for tool in payload["tools"]}
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_same_named_servers_stay_isolated_per_profile(mcp_env):
|
||||
read_tools = set(mcp_env.chat_turn("profile-read"))
|
||||
write_tools = set(mcp_env.chat_turn("profile-write"))
|
||||
assert WRITE_TOOL not in read_tools
|
||||
assert WRITE_TOOL in write_tools
|
||||
assert mcp_env.connection("profile-read") is not mcp_env.connection("profile-write")
|
||||
|
||||
for profile, expect_write in (("profile-read", False), ("profile-write", True)):
|
||||
servers = mcp_env.servers(profile)
|
||||
inventory = mcp_env.tools(profile)
|
||||
names = _tool_names(inventory)
|
||||
assert servers["atlassian"]["status"] == "active"
|
||||
assert READ_TOOLS <= names
|
||||
assert (WRITE_TOOL in names) is expect_write
|
||||
# Status counter and inventory describe the same connection.
|
||||
assert servers["atlassian"]["tool_count"] == inventory["total"] == len(names)
|
||||
assert {tool["server"] for tool in inventory["tools"]} == {"atlassian"}
|
||||
assert servers["_scope"] == SCOPE_PROFILE
|
||||
|
||||
# The root profile configures no MCP server and must not see either profile's tools.
|
||||
default_servers = mcp_env.servers("default")
|
||||
assert set(default_servers) == {"_scope"}
|
||||
assert mcp_env.tools("default")["total"] == 0
|
||||
|
||||
|
||||
def test_status_and_inventory_are_passive(mcp_env):
|
||||
for profile in ("profile-write", "profile-read", "default"):
|
||||
servers = mcp_env.servers(profile)
|
||||
inventory = mcp_env.tools(profile)
|
||||
if profile != "default":
|
||||
assert servers["atlassian"]["status"] == "configured"
|
||||
assert not servers["atlassian"]["active"]
|
||||
assert inventory["unavailable_servers"] == ["atlassian"]
|
||||
assert inventory["total"] == 0
|
||||
with mcp_core._lock:
|
||||
assert not mcp_core._servers
|
||||
assert not mcp_core._server_connecting
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_reload_applies_the_requested_profiles_config_only(mcp_env):
|
||||
# profile-write connected while its config was still read-only (stale runtime).
|
||||
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=True))
|
||||
mcp_env.chat_turn("profile-read")
|
||||
mcp_env.chat_turn("profile-write")
|
||||
read_conn = mcp_env.connection("profile-read")
|
||||
stale_write_conn = mcp_env.connection("profile-write")
|
||||
|
||||
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
|
||||
stale = mcp_env.servers("profile-write")["atlassian"]
|
||||
assert stale["env"]["READ_ONLY_MODE"] == "false"
|
||||
assert WRITE_TOOL not in _tool_names(mcp_env.tools("profile-write"))
|
||||
|
||||
output = mcp_env.reload("profile-write")
|
||||
assert "Reconnected: atlassian" in output
|
||||
|
||||
assert mcp_env.connection("profile-write") is not stale_write_conn
|
||||
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
|
||||
# The other profile's same-named connection was neither stopped nor replaced.
|
||||
assert mcp_env.connection("profile-read") is read_conn
|
||||
assert read_conn.session is not None
|
||||
read_inventory = mcp_env.tools("profile-read")
|
||||
assert WRITE_TOOL not in _tool_names(read_inventory)
|
||||
assert mcp_env.servers("profile-read")["atlassian"]["tool_count"] == read_inventory["total"]
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_reloading_an_adopting_profile_keeps_the_owners_connection(mcp_env):
|
||||
"""Identical routes share one connection; the adopter's reload must not stop it."""
|
||||
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-read", read_only=True))
|
||||
mcp_env.chat_turn("profile-read")
|
||||
mcp_env.chat_turn("profile-write")
|
||||
owner_conn = mcp_env.connection("profile-read")
|
||||
assert mcp_env.connection("profile-write") is None # adopted, not owned
|
||||
# The adopted connection serves profile-write: status, inventory and the
|
||||
# reload summary all count it (it is not "Added" from the user's point of view).
|
||||
assert mcp_env.servers("profile-write")["atlassian"]["status"] == "active"
|
||||
assert READ_TOOLS <= _tool_names(mcp_env.tools("profile-write"))
|
||||
|
||||
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
|
||||
output = mcp_env.reload("profile-write")
|
||||
assert "Reconnected: atlassian" in output and "Added" not in output
|
||||
assert mcp_env.connection("profile-read") is owner_conn
|
||||
assert owner_conn.session is not None
|
||||
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
|
||||
assert WRITE_TOOL not in _tool_names(mcp_env.tools("profile-read"))
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_root_profile_reload_leaves_named_profiles_running(mcp_env):
|
||||
mcp_env.configure("default", atlassian=mcp_env.atlassian("default", read_only=True))
|
||||
mcp_env.chat_turn("default")
|
||||
mcp_env.chat_turn("profile-write")
|
||||
root_conn = mcp_env.connection("default")
|
||||
write_conn = mcp_env.connection("profile-write")
|
||||
assert root_conn is not None and write_conn is not None
|
||||
|
||||
assert "Reconnected: atlassian" in mcp_env.reload("default")
|
||||
assert mcp_env.connection("default") is not root_conn
|
||||
assert mcp_env.connection("profile-write") is write_conn
|
||||
assert write_conn.session is not None
|
||||
assert WRITE_TOOL not in _tool_names(mcp_env.tools("default"))
|
||||
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_root_profile_status_never_shows_a_routed_profiles_connection(mcp_env):
|
||||
"""The agent's launch-profile view is process-wide; the root profile must only
|
||||
report its own (unscoped) connection, never a routed profile's."""
|
||||
mcp_env.configure("default", atlassian=mcp_env.atlassian("default", read_only=True))
|
||||
mcp_env.chat_turn("profile-write")
|
||||
assert mcp_env.connection("profile-write") is not None
|
||||
assert mcp_env.connection("default") is None
|
||||
|
||||
root = mcp_env.servers("default")
|
||||
assert root["_scope"] == SCOPE_PROFILE
|
||||
assert root["atlassian"]["status"] == "configured" and not root["atlassian"]["active"]
|
||||
assert not root["atlassian"]["tool_count"]
|
||||
assert mcp_env.tools("default")["total"] == 0
|
||||
|
||||
mcp_env.chat_turn("default")
|
||||
root = mcp_env.servers("default")
|
||||
root_inventory = mcp_env.tools("default")
|
||||
assert root["atlassian"]["status"] == "active"
|
||||
# Status counter and inventory describe the root profile's own connection
|
||||
# (the agent also registers generic resource/prompt tools per server).
|
||||
assert root["atlassian"]["tool_count"] == root_inventory["total"]
|
||||
assert READ_TOOLS <= _tool_names(root_inventory)
|
||||
assert WRITE_TOOL not in _tool_names(root_inventory)
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_reload_retries_a_server_that_failed_to_spawn(mcp_env):
|
||||
"""A failed spawn sits under the agent's connect backoff (30s+) and never enters
|
||||
the ledger, so a scoped shutdown leaves that backoff alone; the profile's reload
|
||||
must still retry it, without dropping another profile's backoff."""
|
||||
broken = {"command": str(mcp_env.base / "missing-mcp-binary"), "connect_timeout": 5}
|
||||
mcp_env.configure("default", atlassian=broken)
|
||||
mcp_env.configure("profile-write", atlassian=broken)
|
||||
mcp_env.chat_turn("default")
|
||||
mcp_env.chat_turn("profile-write")
|
||||
# A permanent spawn failure is parked (task retained, no session) or dropped,
|
||||
# depending on the agent; either way nothing is connected.
|
||||
for profile in ("default", "profile-write"):
|
||||
parked = mcp_env.connection(profile)
|
||||
assert parked is None or parked.session is None
|
||||
assert mcp_env.servers(profile)["atlassian"]["status"] == "configured"
|
||||
retry_after = getattr(mcp_core, "_server_connect_retry_after", None)
|
||||
if retry_after is None:
|
||||
pytest.skip("agent has no connect-retry cooldown ledger")
|
||||
write_key = (hermes_constants.hermes_home_key(mcp_env.home("profile-write")), "atlassian")
|
||||
with mcp_core._lock:
|
||||
assert write_key in retry_after and "atlassian" in retry_after
|
||||
|
||||
mcp_env.configure("profile-write", atlassian=mcp_env.atlassian("profile-write", read_only=False))
|
||||
assert "Added: atlassian" in mcp_env.reload("profile-write")
|
||||
assert mcp_env.connection("profile-write") is not None
|
||||
assert WRITE_TOOL in _tool_names(mcp_env.tools("profile-write"))
|
||||
with mcp_core._lock:
|
||||
assert write_key not in retry_after
|
||||
assert "atlassian" in retry_after # the root profile's own backoff is untouched
|
||||
assert mcp_env.servers("default")["atlassian"]["status"] == "configured"
|
||||
|
||||
|
||||
def test_unconfirmed_scope_hides_runtime_and_refuses_reload(mcp_env, monkeypatch):
|
||||
"""A live turn's HERMES_HOME mirror on an agent without the pin skews routing."""
|
||||
mcp_env.chat_turn("profile-read")
|
||||
before = dict(mcp_core._servers)
|
||||
if hasattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME"):
|
||||
monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME", None)
|
||||
monkeypatch.setenv("HERMES_HOME", str(mcp_env.home("profile-write"))) # mid-turn mirror
|
||||
|
||||
servers = mcp_env.servers("profile-write")
|
||||
inventory = mcp_env.tools("profile-write")
|
||||
assert servers["atlassian"]["status"] == "configured"
|
||||
assert servers["atlassian"]["tool_count"] is None
|
||||
assert inventory["total"] == 0
|
||||
|
||||
with pytest.raises(RuntimeError, match="scope could not be confirmed"):
|
||||
mcp_env.reload("profile-write")
|
||||
assert dict(mcp_core._servers) == before
|
||||
assert servers["_scope"] == inventory.get("runtime_scope") == SCOPE_UNAVAILABLE
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_disabled_and_failed_servers_are_reported_per_profile(mcp_env):
|
||||
mcp_env.configure(
|
||||
"profile-write",
|
||||
atlassian=mcp_env.atlassian("profile-write", read_only=False),
|
||||
off={**mcp_env.atlassian("profile-write", read_only=False), "enabled": False},
|
||||
broken={"command": str(mcp_env.base / "missing-mcp-binary"), "connect_timeout": 5},
|
||||
invalid="not-a-mapping",
|
||||
)
|
||||
mcp_env.chat_turn("profile-write")
|
||||
servers = mcp_env.servers("profile-write")
|
||||
assert servers["atlassian"]["status"] == "active"
|
||||
assert servers["off"]["status"] == "disabled"
|
||||
assert servers["broken"]["status"] == "configured"
|
||||
assert not servers["broken"]["active"]
|
||||
assert servers["invalid"]["status"] == "invalid_config"
|
||||
inventory = mcp_env.tools("profile-write")
|
||||
assert inventory["unavailable_servers"] == ["broken"]
|
||||
assert {tool["server"] for tool in inventory["tools"]} == {"atlassian"}
|
||||
|
||||
|
||||
def test_runtime_scope_is_restored_after_an_exception(mcp_env):
|
||||
from api.config import _thread_ctx
|
||||
from api.mcp_runtime import mcp_runtime_scope
|
||||
|
||||
previous_env = dict(getattr(_thread_ctx, "env", {}) or {})
|
||||
profiles.set_request_profile("profile-write")
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
with mcp_runtime_scope("test") as view:
|
||||
assert view.trusted
|
||||
assert hermes_constants.get_hermes_home_override() == str(mcp_env.home("profile-write"))
|
||||
assert current_secret_scope() is not None
|
||||
raise ValueError("boom")
|
||||
finally:
|
||||
profiles.clear_request_profile()
|
||||
assert hermes_constants.get_hermes_home_override() is None
|
||||
assert current_secret_scope() is None
|
||||
assert dict(getattr(_thread_ctx, "env", {}) or {}) == previous_env
|
||||
assert os.environ["HERMES_HOME"] == str(mcp_env.base)
|
||||
|
||||
|
||||
@requires_process_home_pin
|
||||
def test_concurrent_profile_reads_and_reload_never_cross_profiles(mcp_env):
|
||||
mcp_env.chat_turn("profile-read")
|
||||
mcp_env.chat_turn("profile-write")
|
||||
read_count = mcp_env.servers("profile-read")["atlassian"]["tool_count"]
|
||||
write_count = mcp_env.servers("profile-write")["atlassian"]["tool_count"]
|
||||
assert read_count != write_count
|
||||
|
||||
barrier = threading.Barrier(3)
|
||||
failures: list[str] = []
|
||||
|
||||
def poll(profile: str, expected: int):
|
||||
barrier.wait()
|
||||
for _ in range(20):
|
||||
srv = mcp_env.servers(profile)["atlassian"]
|
||||
names = _tool_names(mcp_env.tools(profile))
|
||||
allowed = (expected,) if profile == "profile-read" else (expected, 0, None)
|
||||
if srv["tool_count"] not in allowed:
|
||||
failures.append(f"{profile} saw {srv['tool_count']} tools")
|
||||
if profile == "profile-read" and WRITE_TOOL in names:
|
||||
failures.append("profile-read inventory leaked a write tool")
|
||||
|
||||
def reload_write():
|
||||
barrier.wait()
|
||||
mcp_env.reload("profile-write")
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=poll, args=("profile-read", read_count)),
|
||||
threading.Thread(target=poll, args=("profile-write", write_count)),
|
||||
threading.Thread(target=reload_write),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=120)
|
||||
assert not failures
|
||||
assert mcp_env.servers("profile-read")["atlassian"]["tool_count"] == read_count
|
||||
assert mcp_env.servers("profile-write")["atlassian"]["tool_count"] == write_count
|
||||
@@ -0,0 +1,557 @@
|
||||
"""Agent-free contract tests for profile-scoped MCP status, inventory and /reload-mcp.
|
||||
|
||||
``tests/test_mcp_runtime_profile_scope.py`` drives the real Hermes Agent MCP
|
||||
runtime; CI has no Hermes Agent checkout, so this file models the small agent
|
||||
surface WebUI relies on (context-local home override, routed-profile predicate,
|
||||
scoped registry slots, scoped MCP ledger with adoption and connect cooldowns)
|
||||
and pins the WebUI side of the contract: which profile is bound, which scope is
|
||||
reset, what the root profile may see, and the fail-closed paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from api import profiles
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Minimal model of the hermes-agent MCP surface used by WebUI.
|
||||
|
||||
Mirrors the agent's visibility rules: a routed scope sees the connections it
|
||||
owns or adopted; the launch profile's view (scope ``None``) is process-wide.
|
||||
"""
|
||||
|
||||
def __init__(self, *, pin_supported=True, scoped=True):
|
||||
self.override: ContextVar[Optional[str]] = ContextVar("fake_home_override", default=None)
|
||||
self.secret: ContextVar[Optional[dict]] = ContextVar("fake_secret_scope", default=None)
|
||||
self.pinned = None
|
||||
self.servers = {} # ledger key -> connection
|
||||
self.owners = {} # ledger key -> owner scope
|
||||
self.adopters = {} # ledger key -> {adopter scope}
|
||||
self.connecting = set()
|
||||
self.errors = {} # ledger key -> error text
|
||||
self.retry_after = {} # ledger key -> deadline (connect cooldown)
|
||||
self.failures = {} # ledger key -> failure count
|
||||
self.lazy = {} # ledger key -> cfg
|
||||
self.global_tools = {} # tool name -> (toolset, schema)
|
||||
self.scoped_tools = {} # scope -> {tool name -> (toolset, schema)}
|
||||
self.calls = []
|
||||
self.lock = threading.Lock()
|
||||
self.modules = self._build_modules(pin_supported, scoped)
|
||||
|
||||
# hermes_constants
|
||||
def home(self):
|
||||
return self.override.get() or os.environ.get("HERMES_HOME", "")
|
||||
|
||||
@staticmethod
|
||||
def key(path):
|
||||
return str(Path(path).resolve())
|
||||
|
||||
def routed(self):
|
||||
override = self.override.get()
|
||||
anchor = self.pinned or os.environ.get("HERMES_HOME", "")
|
||||
return override is not None and self.key(override) != self.key(anchor)
|
||||
|
||||
def scope(self):
|
||||
return self.key(self.home()) if self.routed() else None
|
||||
|
||||
def _merged(self, scope):
|
||||
return {**self.global_tools, **self.scoped_tools.get(scope, {})}
|
||||
|
||||
def _slot(self, scope):
|
||||
return self.global_tools if scope is None else self.scoped_tools.setdefault(scope, {})
|
||||
|
||||
@staticmethod
|
||||
def ledger_key(name, scope):
|
||||
return name if scope is None else (scope, name)
|
||||
|
||||
def visible(self, key, scope):
|
||||
"""The agent's ``_server_visible_in_scope``: process-wide for scope ``None``."""
|
||||
if scope is None:
|
||||
return True
|
||||
return self.owners.get(key) == scope or scope in self.adopters.get(key, ())
|
||||
|
||||
def connect(self, name, tools, *, scope):
|
||||
key = self.ledger_key(name, scope)
|
||||
self.servers[key] = SimpleNamespace(name=name, session=object(), _registered_tool_names=list(tools))
|
||||
self.owners[key] = scope
|
||||
for tool in tools:
|
||||
self._slot(scope)[tool] = (f"mcp-{name}", {"description": f"{tool} ({scope})"})
|
||||
return self.servers[key]
|
||||
|
||||
def adopt(self, name, *, owner, adopter):
|
||||
"""*adopter* shares *owner*'s identical connection (agent: ``_server_tool_scopes``)."""
|
||||
key = self.ledger_key(name, owner)
|
||||
self.adopters.setdefault(key, set()).add(adopter)
|
||||
for tool in self.servers[key]._registered_tool_names:
|
||||
self._slot(adopter)[tool] = (f"mcp-{name}", {"description": f"{tool} ({adopter})"})
|
||||
|
||||
def park(self, name, *, scope, error="spawn failed"):
|
||||
"""A permanent spawn failure the agent parks: task retained in ``_servers``
|
||||
without a session, error + backoff recorded under its key."""
|
||||
key = self.ledger_key(name, scope)
|
||||
self.servers[key] = SimpleNamespace(name=name, session=None, _registered_tool_names=[])
|
||||
self.owners[key] = scope
|
||||
self.fail(name, scope=scope, error=error)
|
||||
return self.servers[key]
|
||||
|
||||
def fail(self, name, *, scope, error="spawn failed"):
|
||||
"""A connect failure: never in ``_servers``; error + backoff recorded under its key."""
|
||||
key = self.ledger_key(name, scope)
|
||||
self.errors[key] = error
|
||||
self.failures[key] = self.failures.get(key, 0) + 1
|
||||
self.retry_after[key] = float("inf")
|
||||
|
||||
def _build_modules(self, pin_supported, scoped):
|
||||
agent = self
|
||||
hc: Any = types.ModuleType("hermes_constants")
|
||||
hc.set_hermes_home_override = lambda path: agent.override.set(None if path is None else str(path))
|
||||
hc.reset_hermes_home_override = lambda token: agent.override.reset(token)
|
||||
hc.get_hermes_home_override = lambda: agent.override.get()
|
||||
hc.hermes_home_key = lambda path=None: agent.key(path if path is not None else agent.home())
|
||||
if pin_supported:
|
||||
def pin(path):
|
||||
agent.pinned = None if path is None else str(path)
|
||||
hc.pin_process_hermes_home = pin
|
||||
|
||||
agent_pkg: Any = types.ModuleType("agent")
|
||||
agent_pkg.__path__ = []
|
||||
secret: Any = types.ModuleType("agent.secret_scope")
|
||||
secret.set_secret_scope = lambda mapping: agent.secret.set(dict(mapping))
|
||||
secret.reset_secret_scope = lambda token: agent.secret.reset(token)
|
||||
secret.current_secret_scope = lambda: agent.secret.get()
|
||||
if scoped:
|
||||
secret.serves_routed_profile = agent.routed
|
||||
|
||||
tools_pkg: Any = types.ModuleType("tools")
|
||||
tools_pkg.__path__ = []
|
||||
registry_mod: Any = types.ModuleType("tools.registry")
|
||||
|
||||
class Registry:
|
||||
@staticmethod
|
||||
def current_scope_key():
|
||||
return agent.key(agent.home())
|
||||
|
||||
def _view(self):
|
||||
return agent._merged(agent.scope() if scoped else None)
|
||||
|
||||
def get_all_tool_names(self):
|
||||
return sorted(self._view())
|
||||
|
||||
def get_toolset_for_tool(self, name):
|
||||
return self._view().get(name, (None, None))[0]
|
||||
|
||||
def get_schema(self, name):
|
||||
return self._view().get(name, (None, None))[1]
|
||||
|
||||
def snapshot_registration(self, name, *, scope=None):
|
||||
slot = agent.global_tools if scope is None else agent.scoped_tools.get(scope, {})
|
||||
return slot.get(name)
|
||||
|
||||
registry_mod.registry = Registry()
|
||||
|
||||
scope_mod: Any = types.ModuleType("tools.mcp_tool_scope")
|
||||
scope_mod._key_name = lambda key: key[1] if isinstance(key, tuple) else key
|
||||
scope_mod._key_scope = lambda key: key[0] if isinstance(key, tuple) else None
|
||||
|
||||
mcp_mod: Any = types.ModuleType("tools.mcp_tool")
|
||||
mcp_mod._servers = agent.servers
|
||||
mcp_mod._lock = agent.lock
|
||||
mcp_mod._server_scope_keys = agent.owners
|
||||
mcp_mod._server_tool_scopes = agent.adopters
|
||||
mcp_mod._server_connecting = agent.connecting
|
||||
mcp_mod._server_connect_errors = agent.errors
|
||||
mcp_mod._server_connect_retry_after = agent.retry_after
|
||||
mcp_mod._server_connect_failures = agent.failures
|
||||
mcp_mod._lazy_server_configs = agent.lazy
|
||||
|
||||
def get_mcp_status(configured=None):
|
||||
agent.calls.append(("status", agent.override.get(), dict(configured or {})))
|
||||
scope = agent.scope() if scoped else None
|
||||
with agent.lock:
|
||||
# As the agent: keyed by bare name, last key wins (process-wide for scope None).
|
||||
active = {scope_mod._key_name(k): s for k, s in agent.servers.items() if agent.visible(k, scope)}
|
||||
errors = {scope_mod._key_name(k): e for k, e in agent.errors.items() if agent.visible(k, scope)}
|
||||
rows = []
|
||||
for name, cfg in (configured or {}).items():
|
||||
enabled = cfg.get("enabled", True) is not False
|
||||
server = active.get(name)
|
||||
live = server is not None and server.session is not None
|
||||
status = ("connected" if live else "disabled" if not enabled
|
||||
else "failed" if name in errors else "configured")
|
||||
row = {"name": name, "connected": live, "disabled": not enabled, "status": status,
|
||||
"tools": len(server._registered_tool_names) if live else 0}
|
||||
if status == "failed":
|
||||
row["error"] = errors[name]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def shutdown_mcp_servers(*, scope=None, names=None, timeout=15.0):
|
||||
agent.calls.append(("shutdown", scope, None if names is None else set(names)))
|
||||
wildcard = scope is None and names is None
|
||||
with agent.lock:
|
||||
selected = [k for k in agent.servers
|
||||
if wildcard or (agent.owners.get(k) == scope
|
||||
and (names is None or scope_mod._key_name(k) in names))]
|
||||
for key in selected:
|
||||
del agent.servers[key]
|
||||
agent.owners.pop(key, None)
|
||||
agent.adopters.pop(key, None)
|
||||
# Cooldown/error state: the wildcard drops everything, a scoped call
|
||||
# only what it tore down (the agent's ``selected_status``).
|
||||
for key in (list(agent.errors) if wildcard else selected):
|
||||
agent.errors.pop(key, None)
|
||||
agent.retry_after.pop(key, None)
|
||||
agent.failures.pop(key, None)
|
||||
|
||||
def discover_mcp_tools():
|
||||
home = agent.home()
|
||||
agent.calls.append(("discover", agent.override.get()))
|
||||
cfg = yaml.safe_load(Path(home, "config.yaml").read_text()) or {}
|
||||
scope = agent.scope() if scoped else None
|
||||
names = []
|
||||
for name, srv in (cfg.get("mcp_servers") or {}).items():
|
||||
key = agent.ledger_key(name, scope)
|
||||
served = any(scope_mod._key_name(k) == name and agent.visible(k, scope) and scope is not None
|
||||
for k in agent.servers) or key in agent.servers
|
||||
if served:
|
||||
names += [t for k, s in agent.servers.items()
|
||||
if scope_mod._key_name(k) == name for t in s._registered_tool_names]
|
||||
continue
|
||||
if key in agent.retry_after: # backoff honoured: no retry
|
||||
continue
|
||||
if srv.get("fake_fail"):
|
||||
agent.fail(name, scope=scope, error=str(srv["fake_fail"]))
|
||||
continue
|
||||
agent.connect(name, srv.get("fake_tools", []), scope=scope)
|
||||
names += srv.get("fake_tools", [])
|
||||
return names
|
||||
|
||||
mcp_mod.get_mcp_status = get_mcp_status
|
||||
mcp_mod.shutdown_mcp_servers = shutdown_mcp_servers
|
||||
mcp_mod.discover_mcp_tools = discover_mcp_tools
|
||||
return {
|
||||
"hermes_constants": hc,
|
||||
"agent": agent_pkg,
|
||||
"agent.secret_scope": secret,
|
||||
"tools": tools_pkg,
|
||||
"tools.registry": registry_mod,
|
||||
"tools.mcp_tool_scope": scope_mod,
|
||||
"tools.mcp_tool": mcp_mod,
|
||||
"tools.mcp_tool_discovery": mcp_mod,
|
||||
"tools.mcp_tool_lifecycle": mcp_mod,
|
||||
}
|
||||
|
||||
|
||||
READ = ["mcp__atlassian__jira_get_issue"]
|
||||
WRITE = READ + ["mcp__atlassian__jira_create_issue"]
|
||||
|
||||
|
||||
def _install(monkeypatch, tmp_path, **agent_kwargs):
|
||||
agent = FakeAgent(**agent_kwargs)
|
||||
for name, module in agent.modules.items():
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
base = tmp_path / "home"
|
||||
for profile, tools in (("default", None), ("profile-read", READ), ("profile-write", WRITE)):
|
||||
home = base if profile == "default" else base / "profiles" / profile
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
servers = {} if tools is None else {"atlassian": {"command": "fake", "fake_tools": tools}}
|
||||
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(base))
|
||||
monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False)
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
|
||||
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
|
||||
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("", "default"))
|
||||
monkeypatch.setattr(profiles, "_secret_scope_available", None)
|
||||
monkeypatch.setattr(profiles, "_PROCESS_PROFILE_HOME", None)
|
||||
profiles._pin_process_profile_home(base)
|
||||
agent.base = base
|
||||
return agent
|
||||
|
||||
|
||||
def _configure(agent, profile, servers):
|
||||
home = agent.base if profile == "default" else agent.base / "profiles" / profile
|
||||
(home / "config.yaml").write_text(yaml.safe_dump({"mcp_servers": servers}))
|
||||
|
||||
|
||||
def _as_profile(profile, fn, *args):
|
||||
profiles.set_request_profile(profile)
|
||||
try:
|
||||
return fn(*args)
|
||||
finally:
|
||||
profiles.clear_request_profile()
|
||||
|
||||
|
||||
def _call(profile, handler_fn):
|
||||
handler = MagicMock()
|
||||
_as_profile(profile, handler_fn, handler)
|
||||
return json.loads(handler.wfile.write.call_args[0][0])
|
||||
|
||||
|
||||
def _reload(profile):
|
||||
from api.commands import execute_agent_command
|
||||
return _as_profile(profile, execute_agent_command, "/reload-mcp")
|
||||
|
||||
|
||||
def _profile_scope(agent, profile):
|
||||
return agent.key(agent.base / "profiles" / profile)
|
||||
|
||||
|
||||
def _servers_by_name(payload):
|
||||
return {row["name"]: row for row in payload["servers"]}
|
||||
|
||||
|
||||
def test_init_pins_the_process_profile_home_for_the_agent(monkeypatch, tmp_path):
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
assert agent.pinned == str(agent.base)
|
||||
assert profiles.get_process_profile_home() == agent.base
|
||||
|
||||
|
||||
def test_process_wide_home_change_repins_in_the_same_step(monkeypatch, tmp_path):
|
||||
"""``_set_hermes_home`` is the single writer of the process home: startup and
|
||||
``switch_profile(process_wide=True)`` both go through it, so the MCP routing
|
||||
anchor (WebUI and agent pin) can never drift from ``HERMES_HOME``."""
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
new_home = agent.base / "profiles" / "profile-write"
|
||||
profiles._set_hermes_home(new_home)
|
||||
assert os.environ["HERMES_HOME"] == str(new_home)
|
||||
assert profiles.get_process_profile_home() == new_home
|
||||
assert agent.pinned == str(new_home)
|
||||
|
||||
|
||||
def test_status_and_inventory_bind_the_request_profile(monkeypatch, tmp_path):
|
||||
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
agent.connect("atlassian", READ, scope=_profile_scope(agent, "profile-read"))
|
||||
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
|
||||
|
||||
servers = _call("profile-write", _handle_mcp_servers_list)
|
||||
tools = _call("profile-write", _handle_mcp_tools_list)
|
||||
assert servers["runtime_scope"] == tools["runtime_scope"] == "profile"
|
||||
assert servers["servers"][0]["tool_count"] == 2
|
||||
assert {t["name"] for t in tools["tools"]} == set(WRITE)
|
||||
# get_mcp_status ran under the profile's home and was handed the displayed config.
|
||||
status_calls = [c for c in agent.calls if c[0] == "status"]
|
||||
assert all(c[1] == str(agent.base / "profiles" / "profile-write") for c in status_calls)
|
||||
assert all(set(c[2]) == {"atlassian"} for c in status_calls)
|
||||
# Nothing leaked into the request thread after the calls.
|
||||
assert agent.override.get() is None and agent.secret.get() is None
|
||||
|
||||
read_tools = _call("profile-read", _handle_mcp_tools_list)
|
||||
assert {t["name"] for t in read_tools["tools"]} == set(READ)
|
||||
assert _call("default", _handle_mcp_tools_list)["total"] == 0
|
||||
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]
|
||||
|
||||
|
||||
def test_inventory_excludes_the_process_profiles_global_slot(monkeypatch, tmp_path):
|
||||
from api.routes import _handle_mcp_tools_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
agent.connect("atlassian", WRITE, scope=None) # launch profile's unscoped connection
|
||||
agent.connect("atlassian", READ, scope=_profile_scope(agent, "profile-read"))
|
||||
names = {t["name"] for t in _call("profile-read", _handle_mcp_tools_list)["tools"]}
|
||||
assert names == set(READ)
|
||||
|
||||
|
||||
def test_root_profile_status_hides_routed_profiles_runtime(monkeypatch, tmp_path):
|
||||
"""The agent's launch-profile view is process-wide; WebUI narrows it to the
|
||||
root profile's own (unscoped) connections, so a routed profile's live
|
||||
connection or connect error never shows up as the root profile's."""
|
||||
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
_configure(agent, "default", {"atlassian": {"command": "fake", "fake_tools": READ}})
|
||||
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
|
||||
agent.fail("atlassian", scope=_profile_scope(agent, "profile-read"), error="bad token")
|
||||
|
||||
servers = _call("default", _handle_mcp_servers_list)
|
||||
row = _servers_by_name(servers)["atlassian"]
|
||||
assert servers["runtime_scope"] == "profile"
|
||||
assert row["status"] == "configured" and row["active"] is False
|
||||
assert not row["tool_count"]
|
||||
assert _call("default", _handle_mcp_tools_list)["total"] == 0
|
||||
|
||||
# The root profile's own parked task (failed spawn, no session) must not borrow
|
||||
# the routed profile's live same-named connection: the agent's process-wide
|
||||
# view indexes by bare name and the last key wins.
|
||||
agent.park("atlassian", scope=None, error="exec: not found")
|
||||
row = _servers_by_name(_call("default", _handle_mcp_servers_list))["atlassian"]
|
||||
assert row["status"] == "configured" and row["active"] is False and not row["tool_count"]
|
||||
|
||||
# The root profile's own connection is still reported normally.
|
||||
agent.connect("atlassian", READ, scope=None)
|
||||
row = _servers_by_name(_call("default", _handle_mcp_servers_list))["atlassian"]
|
||||
assert row["status"] == "active" and row["tool_count"] == len(READ)
|
||||
assert {t["name"] for t in _call("default", _handle_mcp_tools_list)["tools"]} == set(READ)
|
||||
|
||||
|
||||
def test_adopted_connection_serves_status_inventory_and_reload(monkeypatch, tmp_path):
|
||||
"""A profile that adopted another profile's identical connection sees it as
|
||||
active; its reload counts it and never tears down the owner's connection."""
|
||||
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
read_scope = _profile_scope(agent, "profile-read")
|
||||
write_scope = _profile_scope(agent, "profile-write")
|
||||
owner_conn = agent.connect("atlassian", READ, scope=read_scope)
|
||||
agent.adopt("atlassian", owner=read_scope, adopter=write_scope)
|
||||
|
||||
row = _servers_by_name(_call("profile-write", _handle_mcp_servers_list))["atlassian"]
|
||||
assert row["status"] == "active" and row["tool_count"] == len(READ)
|
||||
assert {t["name"] for t in _call("profile-write", _handle_mcp_tools_list)["tools"]} == set(READ)
|
||||
|
||||
output = _reload("profile-write")
|
||||
assert "Reconnected: atlassian" in output
|
||||
assert "Added" not in output and "No MCP servers connected" not in output
|
||||
assert ("shutdown", write_scope, {"atlassian"}) in agent.calls
|
||||
assert agent.servers[(read_scope, "atlassian")] is owner_conn
|
||||
assert write_scope in agent.adopters[(read_scope, "atlassian")]
|
||||
|
||||
|
||||
def test_skewed_routing_hides_runtime_and_refuses_reload(monkeypatch, tmp_path):
|
||||
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path, pin_supported=False)
|
||||
agent.connect("atlassian", READ, scope=None)
|
||||
monkeypatch.setenv("HERMES_HOME", str(agent.base / "profiles" / "profile-write")) # turn mirror
|
||||
|
||||
servers = _call("profile-write", _handle_mcp_servers_list)
|
||||
assert servers["runtime_scope"] == "unavailable"
|
||||
assert servers["servers"][0]["status"] == "configured"
|
||||
assert servers["servers"][0]["tool_count"] is None
|
||||
assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0
|
||||
with pytest.raises(RuntimeError, match="could not be confirmed"):
|
||||
_reload("profile-write")
|
||||
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]
|
||||
assert "atlassian" in agent.servers
|
||||
|
||||
|
||||
def test_reload_resets_only_the_requested_profile(monkeypatch, tmp_path):
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
read_scope = _profile_scope(agent, "profile-read")
|
||||
write_scope = _profile_scope(agent, "profile-write")
|
||||
read_conn = agent.connect("atlassian", READ, scope=read_scope)
|
||||
agent.connect("atlassian", READ, scope=write_scope) # stale write-profile connection
|
||||
|
||||
output = _reload("profile-write")
|
||||
assert "Reconnected: atlassian" in output
|
||||
assert ("shutdown", write_scope, {"atlassian"}) in agent.calls
|
||||
assert ("discover", str(agent.base / "profiles" / "profile-write")) in agent.calls
|
||||
assert agent.servers[(read_scope, "atlassian")] is read_conn
|
||||
assert agent.servers[(write_scope, "atlassian")]._registered_tool_names == WRITE
|
||||
|
||||
|
||||
def test_reload_retries_the_profiles_failed_servers_only(monkeypatch, tmp_path):
|
||||
"""A server that failed to spawn is under connect backoff and absent from the
|
||||
ledger, so a scoped shutdown leaves its cooldown alone; the reload must still
|
||||
retry it (as the process-wide wildcard did) without touching other owners'."""
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
write_scope = _profile_scope(agent, "profile-write")
|
||||
agent.fail("atlassian", scope=write_scope, error="exec: not found")
|
||||
agent.fail("atlassian", scope=None, error="root still broken") # root's own backoff
|
||||
|
||||
_configure(agent, "profile-write", {"atlassian": {"command": "fake", "fake_tools": WRITE}})
|
||||
output = _reload("profile-write")
|
||||
assert "Added: atlassian" in output
|
||||
assert ("shutdown", write_scope, set()) in agent.calls
|
||||
assert agent.servers[(write_scope, "atlassian")]._registered_tool_names == WRITE
|
||||
assert (write_scope, "atlassian") not in agent.errors
|
||||
assert "atlassian" in agent.retry_after and "atlassian" in agent.errors # root untouched
|
||||
|
||||
|
||||
def test_root_profile_reload_never_uses_the_wildcard_shutdown(monkeypatch, tmp_path):
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
_configure(agent, "default", {"atlassian": {"command": "fake", "fake_tools": READ}})
|
||||
agent.connect("atlassian", READ, scope=None)
|
||||
write_conn = agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
|
||||
|
||||
assert "Reconnected: atlassian" in _reload("default")
|
||||
shutdowns = [c for c in agent.calls if c[0] == "shutdown"]
|
||||
assert shutdowns == [("shutdown", None, {"atlassian"})]
|
||||
assert agent.servers[(_profile_scope(agent, "profile-write"), "atlassian")] is write_conn
|
||||
|
||||
|
||||
def test_legacy_agent_keeps_process_wide_reload_but_reads_the_request_profile(monkeypatch, tmp_path):
|
||||
from api.routes import _handle_mcp_servers_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path, pin_supported=False, scoped=False)
|
||||
assert _call("profile-write", _handle_mcp_servers_list)["runtime_scope"] == "legacy_process"
|
||||
_reload("profile-write")
|
||||
assert ("shutdown", None, None) in agent.calls
|
||||
assert ("discover", str(agent.base / "profiles" / "profile-write")) in agent.calls
|
||||
|
||||
|
||||
def test_status_read_survives_an_uninspectable_get_mcp_status(monkeypatch, tmp_path):
|
||||
"""Signature introspection failing must fall back to the bare call, not blank
|
||||
every server's status."""
|
||||
from api.routes import _handle_mcp_servers_list
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
agent.connect("atlassian", WRITE, scope=_profile_scope(agent, "profile-write"))
|
||||
real = agent.modules["tools.mcp_tool"].get_mcp_status
|
||||
|
||||
class Uninspectable:
|
||||
@property
|
||||
def __signature__(self):
|
||||
raise ValueError("no signature")
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
assert not args and not kwargs, "bare call expected"
|
||||
return real(configured={"atlassian": {"command": "fake"}})
|
||||
|
||||
monkeypatch.setattr(agent.modules["tools.mcp_tool"], "get_mcp_status", Uninspectable())
|
||||
row = _servers_by_name(_call("profile-write", _handle_mcp_servers_list))["atlassian"]
|
||||
assert row["status"] == "active" and row["tool_count"] == len(WRITE)
|
||||
|
||||
|
||||
def test_ledger_key_helpers_prefer_the_agents_and_fall_back_without_them(monkeypatch, tmp_path):
|
||||
from api import mcp_runtime
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
assert mcp_runtime.mcp_key_name(("scope-a", "atlassian")) == "atlassian"
|
||||
assert mcp_runtime.mcp_key_scope(("scope-a", "atlassian")) == "scope-a"
|
||||
assert mcp_runtime.mcp_key_name("atlassian") == "atlassian"
|
||||
assert mcp_runtime.mcp_key_scope("atlassian") is None
|
||||
monkeypatch.delitem(sys.modules, "tools.mcp_tool_scope")
|
||||
assert mcp_runtime.mcp_key_name(("scope-a", "atlassian")) == "atlassian"
|
||||
assert mcp_runtime.mcp_key_scope("atlassian") is None
|
||||
assert mcp_runtime.accepts_keywords(len, "scope") is False
|
||||
assert mcp_runtime.accepts_keywords(agent.modules["tools.mcp_tool"].shutdown_mcp_servers, "scope", "names")
|
||||
|
||||
|
||||
def test_readonly_profile_scope_default_is_unchanged_and_root_is_opt_in(monkeypatch, tmp_path):
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
with profiles.profile_env_for_active_request_readonly("t") as bound:
|
||||
assert bound is False and agent.override.get() is None
|
||||
with profiles.profile_env_for_active_request_readonly("t", include_root=True) as bound:
|
||||
assert bound is True and agent.override.get() == str(agent.base)
|
||||
assert agent.override.get() is None
|
||||
|
||||
|
||||
def test_runtime_scope_restores_context_after_exception(monkeypatch, tmp_path):
|
||||
from api.mcp_runtime import mcp_runtime_scope
|
||||
|
||||
agent = _install(monkeypatch, tmp_path)
|
||||
with pytest.raises(KeyError):
|
||||
_as_profile("profile-write", lambda: _raise_inside(mcp_runtime_scope, agent))
|
||||
assert agent.override.get() is None and agent.secret.get() is None
|
||||
|
||||
|
||||
def _raise_inside(scope_factory, agent):
|
||||
with scope_factory("t") as view:
|
||||
assert view.trusted and agent.override.get() is not None and agent.secret.get() is not None
|
||||
raise KeyError("boom")
|
||||
Reference in new issue
Block a user