feat(gateway): key-addressed plugins.manage rows + portable MCP toolset fold-in

plugins.manage list rows now carry the canonical registry key and a
portable flag (Agent Plugins v1 plugin.json packages), and toggles
address the key — bare names collide across category dirs
(image_gen/fal vs video_gen/fal), so name-addressed toggles flipped
both. Portable packages' in-memory MCP servers also fold into
enabled_mcp_server_names(); without that their tools registered with
the MCP runtime but never reached the model's schema.
This commit is contained in:
Brooklyn Nicholson 2026-08-08 17:24:59 -05:00
parent 51597c5e07
commit a60b492e07
3 changed files with 56 additions and 12 deletions

View File

@ -1061,6 +1061,21 @@ def _read_manifest_info(d: Path, prefix: str):
return name, version, description, key
def _is_portable_plugin_dir(dir_path) -> bool:
"""True when *dir_path* is an Agent Plugins v1 package (``plugin.json``
only a native ``plugin.yaml`` takes precedence, matching the loader)."""
try:
d = Path(dir_path)
if not d.is_dir():
return False
if (d / "plugin.yaml").exists() or (d / "plugin.yml").exists():
return False
portable_file = d / "plugin.json"
return portable_file.exists() or portable_file.is_symlink()
except OSError:
return False
def _scan_level(
base: Path,
source: str,

View File

@ -2123,21 +2123,38 @@ def _parse_enabled_flag(value, default: bool = True) -> bool:
def enabled_mcp_server_names(config: dict) -> Set[str]:
"""Names of MCP servers globally enabled in config.yaml.
"""Names of MCP servers globally enabled in config.yaml or by a plugin.
Shared by the gateway/CLI platform resolver (``_get_platform_tools``) and
the cron per-job toolset resolver (``cron.scheduler``) so every path agrees
on MCP membership. A server is enabled unless its config sets an explicitly
falsey ``enabled`` (per ``_parse_enabled_flag``: false/0/no/off) a missing
flag or an unrecognized value is treated as enabled.
Portable Agent Plugins contribute MCP servers in-memory rather than via
``config.yaml`` (see ``PluginManager.get_portable_mcp_servers``). Those are
included here so their tools fold into platform toolsets like native
servers do the user's opt-in is enabling the plugin itself. Without this,
a portable server registers with the MCP runtime but its tools never reach
the model's schema.
"""
mcp_servers = (config or {}).get("mcp_servers") or {}
return {
names = {
str(name)
for name, server_cfg in mcp_servers.items()
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
}
try:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins()
portable = set(get_plugin_manager().get_portable_mcp_servers())
# Native config wins on a name collision (mirrors _load_mcp_config).
names |= portable - set(mcp_servers)
except Exception:
logger.debug("Failed to include portable MCP servers", exc_info=True)
return names
def _exempt_explicit_platform_native(

View File

@ -1803,10 +1803,10 @@ def _(rid, params: dict) -> dict:
agree on what's installed and what's enabled.
Actions:
- ``list`` {"plugins": [{name, version, description, source,
status}], "user_count": N, "bundled_count": M}
- ``toggle`` flip ``name`` based on ``enable`` (bool). Returns the
refreshed row plus {"ok", "unchanged"}.
- ``list`` {"plugins": [{name, key, version, description, source,
status, portable}], "user_count": N, "bundled_count": M}
- ``toggle`` flip ``key`` (or ``name``) based on ``enable`` (bool).
Returns the refreshed row plus {"ok", "unchanged"}.
"""
action = params.get("action", "list")
try:
@ -1814,6 +1814,7 @@ def _(rid, params: dict) -> dict:
_discover_all_plugins,
_get_disabled_set,
_get_enabled_set,
_is_portable_plugin_dir,
_plugin_status,
)
@ -1827,10 +1828,17 @@ def _(rid, params: dict) -> dict:
out.append(
{
"name": name,
# Canonical registry key (e.g. ``image_gen/fal``). Names
# can collide across category dirs — both fal backends
# are named "fal" — so toggles must address the key.
"key": key,
"version": str(version or ""),
"description": desc or "",
"source": source,
"status": _plugin_status(name, enabled, disabled, key=key),
# Agent Plugins v1 package (plugin.json — the portable
# skills/MCP format) vs a native Hermes plugin.
"portable": _is_portable_plugin_dir(_dir),
}
)
return out
@ -1850,20 +1858,24 @@ def _(rid, params: dict) -> dict:
if action == "toggle":
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
name = (params.get("name") or "").strip()
if not name:
return _err(rid, 4019, "plugins.toggle requires a 'name'")
# Prefer the canonical key — bare names are ambiguous when two
# category plugins share one (image_gen/fal vs video_gen/fal).
ident = (params.get("key") or params.get("name") or "").strip()
if not ident:
return _err(rid, 4019, "plugins.toggle requires a 'key' or 'name'")
enable = bool(params.get("enable"))
result = dashboard_set_agent_plugin_enabled(name, enabled=enable)
result = dashboard_set_agent_plugin_enabled(ident, enabled=enable)
if not result.get("ok"):
return _err(rid, 5026, result.get("error") or "toggle failed")
row = next((r for r in _rows() if r["name"] == name), None)
row = next(
(r for r in _rows() if ident in (r["key"], r["name"])), None
)
return _ok(
rid,
{
"ok": True,
"unchanged": bool(result.get("unchanged")),
"name": name,
"name": ident,
"plugin": row,
},
)