Files
tancouandClaude Fable 5.1 3b7b5cbe7c fix(mcp): reconcile an emptied profile config on reload; list portable plugin tools
Review gate on #7720 (two regressions reproduced against master with the
installed Agent):

1. Deleting a profile's last MCP server left ADOPTED tools callable after
   /reload-mcp. The scoped teardown only stops connections the profile owns,
   and the Agent's discover_mcp_tools() returns before its reconcile step
   when the profile's config has no server, so an adopted shared connection
   was never judged against the config. /reload-mcp now calls
   register_mcp_servers({}) under the profile's scope before rediscovery:
   the Agent's overlay heal detaches only this profile's overlay for servers
   its config no longer has; the owner's connection keeps running (master's
   wildcard shutdown stopped the owner too).

2. Portable plugin MCP servers are merged by the Agent at runtime and never
   appear in the profile's raw ``mcp_servers``; the registry fallback
   required a config entry and dropped every plugin tool from
   /api/mcp/tools and the Notes inventory. The profile's own registry slot
   (registry_tool_owned_by_view) is the isolation check; the config is not
   an allowlist any more.

Tests (red before, green after): agent-free unit tests for both (the fake
agent now models the overlay heal and discovery's empty-config early
return), and a real-Agent integration test for the adopted-then-deleted
case (owner connection survives, adopter slot emptied, "Removed" reported).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:53:02 +02:00

557 lines
21 KiB
Python

"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend.
This module is the single integration point with hermes_cli.commands.
If hermes-agent is unavailable the endpoint degrades to an empty list
so the frontend can still load with WEBUI_ONLY commands.
"""
from __future__ import annotations
from contextlib import nullcontext
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
# Commands that are gateway_only in the agent registry -- webui never
# wants to expose them (sethome, restart, update etc.) even if a future
# agent version drops the gateway_only flag. /commands is the agent's
# own command-listing command; webui has its own /help that calls
# cmdHelp() locally, so /commands would be redundant and confusing.
_NEVER_EXPOSE: frozenset[str] = frozenset({
'sethome', 'restart', 'update', 'commands',
})
# Narrow agent-side execution allowlist for /api/commands/exec.
_AGENT_COMMAND_ALIASES = {
'reload_mcp': 'reload-mcp',
'reload_skills': 'reload-skills',
'codex_runtime': 'codex-runtime',
}
_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'reload-skills', 'codex-runtime', 'credits'})
_RELOAD_MCP_LOCK = threading.Lock()
_RELOAD_SKILLS_LOCK = threading.Lock()
_CODEX_RUNTIME_LOCK = threading.Lock()
def _parse_agent_command(command: str) -> tuple[str, str]:
"""Return ``(canonical_name, arg_string)`` from slash-command text."""
cmd_base, arg_string = _parse_slash_command(command)
return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base), arg_string
def _parse_slash_command(command: str) -> tuple[str, str]:
"""Return ``(command_name, arg_string)`` from slash-command text."""
raw = str(command or "").strip()
if not raw:
raise ValueError("command is required")
cmd_text = raw[1:] if raw.startswith("/") else raw
cmd_parts = cmd_text.split(maxsplit=1)
cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower()
if not cmd_base:
raise ValueError("command is required")
return cmd_base, cmd_parts[1] if len(cmd_parts) > 1 else ""
def _bundle_profile_context(purpose: str):
"""Resolve the active-profile env wrapper used by bundle APIs."""
try:
from api.profiles import profile_env_for_active_request
except ImportError:
return nullcontext()
return profile_env_for_active_request(purpose, logger_override=logger)
def _normalize_agent_command_name(command: str) -> str:
"""Normalize slash text to a canonical command name."""
canonical, _arg_string = _parse_agent_command(command)
return canonical
def list_commands(_registry=None) -> list[dict[str, Any]]:
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
Returns empty list if hermes_cli is not installed (graceful
degradation -- the frontend has its own fallback minimum set).
Args:
_registry: Optional injected registry for testing. When None
(production), imports COMMAND_REGISTRY from hermes_cli.
"""
if _registry is None:
try:
from hermes_cli.commands import COMMAND_REGISTRY as _registry
except ImportError:
logger.warning("hermes_cli.commands not importable -- /api/commands returns []")
return []
out: list[dict[str, Any]] = []
for cmd in _registry:
if cmd.gateway_only:
continue
if cmd.name in _NEVER_EXPOSE:
continue
out.append({
'name': cmd.name,
'description': cmd.description,
'category': cmd.category,
'aliases': list(cmd.aliases),
'args_hint': cmd.args_hint,
'subcommands': list(cmd.subcommands),
'cli_only': bool(cmd.cli_only),
'gateway_only': bool(cmd.gateway_only),
})
# Include plugin-registered slash commands
try:
from hermes_cli.plugins import get_plugin_commands
plugin_cmds = get_plugin_commands() or {}
existing_names = {c['name'] for c in out}
for cmd_name, cmd_info in plugin_cmds.items():
if cmd_name in existing_names or cmd_name in _NEVER_EXPOSE:
continue
out.append({
'name': cmd_name,
'description': str(cmd_info.get('description', 'Plugin command')),
'category': 'Plugin',
'aliases': [],
'args_hint': str(cmd_info.get('args_hint', '')),
'subcommands': [],
'cli_only': False,
'gateway_only': False,
})
except Exception:
pass
return out
def list_command_bundles() -> list[dict[str, Any]]:
"""Return installed skill bundles for the active WebUI profile."""
try:
from agent.skill_bundles import list_bundles as _list_bundles
except ImportError:
logger.debug("agent.skill_bundles not importable -- /api/commands/bundles returns []")
return []
try:
with _bundle_profile_context("/api/commands/bundles"):
bundles = _list_bundles() or []
except Exception:
logger.warning("Failed to list skill bundles", exc_info=True)
return []
out: list[dict[str, Any]] = []
for bundle in bundles:
slug = str((bundle or {}).get("slug", "")).strip().lower()
if not slug:
continue
skills = list((bundle or {}).get("skills") or [])
out.append({
"name": slug,
"description": str((bundle or {}).get("description") or "").strip() or "Skill bundle",
"skill_count": len(skills),
"source": "bundle",
})
return out
def resolve_bundle_command(command: str) -> dict[str, Any]:
"""Expand a bundle slash command into the backend invocation payload."""
bundle_name, user_instruction = _parse_slash_command(command)
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
except ImportError as exc:
logger.warning("Skill bundle runtime unavailable", exc_info=True)
raise RuntimeError("Skill bundle runtime unavailable") from exc
try:
with _bundle_profile_context("/api/commands/bundles/resolve"):
bundle_key = resolve_bundle_command_key(bundle_name)
if bundle_key is None:
raise KeyError(bundle_name)
bundle_result = build_bundle_invocation_message(bundle_key, user_instruction)
except (KeyError, ValueError, RuntimeError):
raise
except Exception as exc:
logger.warning("Failed to resolve skill bundle command", exc_info=True)
raise RuntimeError("Skill bundle command unavailable") from exc
if not bundle_result:
raise RuntimeError("Bundle command returned no invocation text")
message, loaded_skills, missing_skills = bundle_result
resolved_message = str(message or "").strip()
if not resolved_message:
raise RuntimeError("Bundle command returned no invocation text")
return {
"name": bundle_key.lstrip("/"),
"source": "bundle",
"message": resolved_message,
"loaded_skills": list(loaded_skills or []),
"missing_skills": list(missing_skills or []),
}
def execute_agent_command(command: str) -> str:
"""Execute a narrow allowlist of agent-side runtime commands."""
canonical, arg_string = _parse_agent_command(command)
if canonical not in _ALLOWED_AGENT_COMMANDS:
raise KeyError(canonical)
if canonical == 'reload-mcp':
return _run_reload_mcp_command()
if canonical == 'reload-skills':
return _run_reload_skills_command()
if canonical == 'codex-runtime':
return _run_codex_runtime_command(arg_string)
if canonical == 'credits':
return _run_credits_command()
raise KeyError(canonical)
def _run_codex_runtime_command(arg_string: str) -> str:
"""Execute Hermes' shared Codex runtime switch for the active profile."""
try:
from hermes_cli.codex_runtime_switch import apply, parse_args
except Exception as exc:
logger.warning("Codex runtime switch unavailable", exc_info=True)
raise RuntimeError("Codex runtime switch unavailable") from exc
new_value, errors = parse_args(arg_string)
if errors:
return "\n".join(str(error) for error in errors)
with _CODEX_RUNTIME_LOCK:
try:
from api import config as webui_config
active_config = webui_config.get_config()
def _persist_config(config_data: dict) -> None:
webui_config._save_yaml_config_file(
webui_config._get_config_path(),
config_data,
)
webui_config.reload_config()
status = apply(active_config, new_value, persist_callback=_persist_config)
except Exception as exc:
logger.warning("Failed to execute /codex-runtime", exc_info=True)
raise RuntimeError("Failed to update Codex runtime") from exc
return str(getattr(status, "message", "") or "(no output)")
def _run_reload_mcp_command() -> str:
"""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"
)
discover_mcp_tools = agent_attr(
"tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery"
)
try:
register_mcp_servers = agent_attr(
"tools.mcp_tool", "register_mcp_servers", "tools.mcp_tool_discovery"
)
except Exception:
register_mcp_servers = None # older agent: no overlay reconciliation
except Exception as exc:
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:
# 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)
# A connection this profile ADOPTED (another profile's identical
# route) is not torn down above, and ``discover_mcp_tools()``
# returns before its reconcile step when the profile's config
# has no server left, so the adopted tools would stay callable.
# ``register_mcp_servers({})`` judges every server serving this
# scope against the profile's config on disk and detaches only
# this profile's overlay; the owner's connection keeps running.
if register_mcp_servers is not None and view.registry_scope is not None:
register_mcp_servers({})
new_tools = discover_mcp_tools()
with _lock:
connected_servers = live_names()
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
added = connected_servers - old_servers
removed = old_servers - connected_servers
reconnected = connected_servers & old_servers
lines = ["Reloaded MCP servers from configuration."]
if reconnected:
lines.append(f"Reconnected: {', '.join(sorted(reconnected))}")
if added:
lines.append(f"Added: {', '.join(sorted(added))}")
if removed:
lines.append(f"Removed: {', '.join(sorted(removed))}")
if connected_servers:
lines.append(f"{len(new_tools or [])} tool(s) available across {len(connected_servers)} server(s)")
else:
lines.append("No MCP servers connected")
if not reconnected and not added and not removed:
lines.append("Tooling state was already current")
return "\n".join(lines)
def _run_reload_skills_command() -> str:
"""Re-scan the installed skills directory and summarize the diff."""
with _RELOAD_SKILLS_LOCK:
try:
from agent.skill_commands import reload_skills
except Exception as exc:
logger.warning("Failed to import skills runtime for /reload-skills", exc_info=True)
raise RuntimeError("Skills runtime unavailable") from exc
try:
result = reload_skills() or {}
except Exception as exc:
logger.warning("Failed to reload skills", exc_info=True)
raise RuntimeError("Failed to reload skills") from exc
added = result.get("added", [])
removed = result.get("removed", [])
unchanged = result.get("unchanged", [])
total = int(result.get("total", 0) or 0)
def _names(items: Any) -> list[str]:
out: list[str] = []
for item in items or []:
if isinstance(item, dict):
name = str(item.get("name", "")).strip()
else:
name = str(item).strip()
if name:
out.append(name)
return out
added_names = _names(added)
removed_names = _names(removed)
lines = [
"Reloaded skills from disk.",
f"Added: {len(added_names)}",
f"Removed: {len(removed_names)}",
f"Unchanged: {len(list(unchanged or []))}",
f"Total skills: {total}",
]
if added_names:
lines.append(f"Added skills: {', '.join(sorted(added_names))}")
if removed_names:
lines.append(f"Removed skills: {', '.join(sorted(removed_names))}")
return "\n".join(lines)
def _run_credits_command() -> str:
"""Render Hermes' shared credits view for the WebUI slash-command path."""
try:
from agent.account_usage import build_credits_view
except Exception:
logger.warning("Failed to import credits view runtime", exc_info=True)
return "Couldn't fetch credits right now."
try:
view = build_credits_view(markdown=True)
except Exception:
logger.warning("Failed to build /credits view", exc_info=True)
return "Couldn't fetch credits right now."
if not getattr(view, "logged_in", False):
return "Not logged into Nous. Run `hermes auth login nous` in Hermes CLI, then try /credits again."
lines = ["💳 **Nous credits**"]
for line in tuple(getattr(view, "balance_lines", ()) or ()):
if str(line).lstrip().startswith("📈"):
continue
lines.append(str(line))
identity_line = str(getattr(view, "identity_line", "") or "").strip()
if identity_line:
lines.append("")
lines.append(identity_line)
topup_url = str(getattr(view, "topup_url", "") or "").strip()
if topup_url:
lines.append("")
lines.append(f"Top up: {topup_url}")
lines.append("Complete your top-up in the browser; credits will appear in /credits shortly.")
return "\n".join(lines)
def _load_config_for_moa_resolution() -> dict:
from hermes_cli.config import load_config
cfg = load_config()
return cfg if isinstance(cfg, dict) else {}
def resolve_moa_config(preset: str | None = None) -> dict:
try:
from hermes_cli.moa_config import moa_usage, normalize_moa_config
except ImportError as exc:
raise RuntimeError("MoA runtime unavailable (hermes-agent not installed or too old)") from exc
try:
from hermes_cli.moa_config import resolve_moa_preset
except ImportError:
resolve_moa_preset = None
try:
cfg = _load_config_for_moa_resolution()
moa_raw = cfg.get("moa") if isinstance(cfg, dict) else {}
moa_cfg = normalize_moa_config(moa_raw)
except Exception:
moa_raw = {}
moa_cfg = normalize_moa_config({})
preset_name = str(preset or moa_cfg.get("default_preset") or "default").strip()
if preset_name not in (moa_cfg.get("presets") or {}):
preset_name = str(moa_cfg.get("default_preset") or "default")
selected = {}
if resolve_moa_preset is not None:
try:
selected = resolve_moa_preset(moa_raw, preset_name)
if not isinstance(selected, dict):
selected = {}
except Exception:
selected = {}
preset_name = str(moa_cfg.get("default_preset") or "default")
resolved = dict(moa_cfg)
resolved.update(selected)
resolved["preset"] = preset_name
resolved["usage"] = moa_usage()
return resolved
def execute_plugin_command(command: str) -> str:
"""Execute a plugin-registered slash command and return printable output.
Unknown commands raise ``KeyError`` so the HTTP layer can return 404.
Plugin handler failures are returned as output text instead of surfacing as
transport errors, matching Hermes' existing slash-command UX.
"""
raw = str(command or "").strip()
if not raw:
raise ValueError("command is required")
cmd_text = raw[1:] if raw.startswith("/") else raw
cmd_parts = cmd_text.split(maxsplit=1)
cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower()
cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else ""
if not cmd_base:
raise ValueError("command is required")
try:
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
except ImportError as exc:
logger.warning("Plugin command runtime unavailable", exc_info=True)
raise RuntimeError("plugin command runtime unavailable") from exc
try:
handler = get_plugin_command_handler(cmd_base)
except Exception as exc:
logger.warning("Plugin command lookup failed for %r", cmd_base, exc_info=True)
raise RuntimeError("plugin command lookup failed") from exc
if not handler:
raise KeyError(cmd_base)
try:
result = resolve_plugin_command_result(handler(cmd_arg))
return str(result or "(no output)")
except Exception as exc:
# Don't leak raw exception str (paths, env, internal state) to the
# user-facing chat. Type name is enough for the user to know what
# class of failure occurred; full traceback lives in the server log.
logger.warning("Plugin command %r execution failed", cmd_base, exc_info=True)
return f"Plugin command error: {type(exc).__name__}"