diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index bdc50e933980d..9d1c744c53cf7 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -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, diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b1dd507ed1e10..78503935bdfde 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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( diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index c1b56ee213a69..4012116b30941 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -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, }, )