diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index c6413109ac..99f12776e9 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -27,9 +27,6 @@ _MANIFEST_VERSION = 1 _INSTALL_DIR_VAR = "${INSTALL_DIR}" -# ─── Data classes ──────────────────────────────────────────────────────────── - - @dataclass class EnvVarSpec: name: str @@ -43,8 +40,7 @@ class EnvVarSpec: class AuthSpec: type: str # "api_key" | "oauth" | "none" env: List[EnvVarSpec] = field(default_factory=list) - # OAuth-specific (third-party provider like Google) - provider: Optional[str] = None + provider: Optional[str] = None # OAuth-specific (third-party provider like Google) scopes: List[str] = field(default_factory=list) env_var: Optional[str] = None @@ -56,8 +52,8 @@ class TransportSpec: args: List[str] = field(default_factory=list) url: Optional[str] = None version: Optional[str] = None # informational, pinned - # Static env for the stdio subprocess (telemetry opt-outs, mode flags). NOT for secrets — - # credentials go through auth.env so they are prompted for and land in ~/.hermes/.env. + # Static env for the stdio subprocess (telemetry opt-outs, mode flags). NOT for secrets — those + # go through auth.env so they are prompted for and land in ~/.hermes/.env. env: Dict[str, str] = field(default_factory=dict) @@ -72,15 +68,15 @@ class InstallSpec: @dataclass class ToolsSpec: - """Manifest-side tool-selection hints: pre-check state for the install checklist and the - fallback selection when the probe fails (see install_entry()).""" + """Manifest-side tool-selection hints (see _apply_tool_selection()). + + ``default_enabled``: pre-checked in the install checklist / applied directly on probe failure; + None => all pre-checked (no filter written on failure). ``default_excluded``: exclude-mode + counterpart written to ``tools.exclude`` — everything NOT matching stays enabled, including tools + the server adds later (for huge OpenAPI-derived surfaces). Mutually exclusive. + """ - # Pre-checked (or applied directly on probe failure). None => all probed tools pre-checked - # (or no filter written on probe failure). default_enabled: Optional[List[str]] = None - # Exclude-mode counterpart written to ``tools.exclude``: everything NOT matching stays enabled, - # including tools the server adds later. For huge auto-generated surfaces (OpenAPI-derived - # MCPs). Mutually exclusive with ``default_enabled``. default_excluded: Optional[List[str]] = None @@ -110,9 +106,6 @@ class CatalogEntry: manifest_path: Path = field(default_factory=Path) -# ─── Manifest loader ───────────────────────────────────────────────────────── - - class CatalogError(Exception): """Manifest parse/validation failure or install error.""" @@ -130,8 +123,7 @@ def _parse_env_spec(raw: Any) -> EnvVarSpec: raise CatalogError(f"invalid env var name: {name!r}") return EnvVarSpec( name=name, prompt=raw.get("prompt") or name, required=bool(raw.get("required", True)), - secret=bool(raw.get("secret", True)), default=str(raw.get("default") or ""), - ) + secret=bool(raw.get("secret", True)), default=str(raw.get("default") or "")) def _require_mapping(path: Path, key: str, raw: Any) -> dict: @@ -160,12 +152,13 @@ def _parse_transport(path: Path, raw: Any) -> TransportSpec: raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'") args = _require_list(path, "transport.args", transport_raw.get("args") or []) env_raw = transport_raw.get("env") or {} - if not isinstance(env_raw, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()): + if not isinstance(env_raw, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items() + ): raise CatalogError(f"{path}: transport.env must be a mapping of string to string") transport = TransportSpec( type=t_type, command=transport_raw.get("command"), args=[str(a) for a in args], - url=transport_raw.get("url"), version=transport_raw.get("version"), env=dict(env_raw), - ) + url=transport_raw.get("url"), version=transport_raw.get("version"), env=dict(env_raw)) if t_type == "stdio" and not transport.command: raise CatalogError(f"{path}: stdio transport requires 'command'") if t_type == "http" and not transport.url: @@ -189,12 +182,10 @@ def _parse_auth(path: Path, raw: Any, name: str, http: bool) -> AuthSpec: if all(spec.name != _required_key for spec in env_list): raise CatalogError( f"{path}: http + api_key auth requires auth.env to declare " - f"'{_required_key}' (the key the Authorization header references)" - ) + f"'{_required_key}' (the key the Authorization header references)") return AuthSpec( type=a_type, env=env_list, provider=auth_raw.get("provider"), - scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"), - ) + scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var")) def _parse_tools(path: Path, raw: Any) -> ToolsSpec: @@ -222,8 +213,7 @@ def _parse_suggest(path: Path, suggest_raw: Any) -> Optional[SuggestSpec]: # Matching is case-insensitive whole-word / host-suffix: store lowercase so UIs needn't re-normalize. return SuggestSpec( keywords=[k.strip().lower() for k in kw_raw], - hosts=[h.strip().lower().lstrip(".") for h in hosts_raw], - ) + hosts=[h.strip().lower().lstrip(".") for h in hosts_raw]) def _parse_install(path: Path, install_raw: Any) -> Optional[InstallSpec]: @@ -254,8 +244,7 @@ def _parse_manifest(path: Path) -> CatalogEntry: if mv != _MANIFEST_VERSION: raise CatalogError( f"{path}: manifest_version {mv!r} unsupported " - f"(this Hermes understands version {_MANIFEST_VERSION})" - ) + f"(this Hermes understands version {_MANIFEST_VERSION})") name = data.get("name") or "" if not name or not re.match(r"^[A-Za-z0-9_-]+$", name): raise CatalogError(f"{path}: invalid or missing 'name'") @@ -279,8 +268,7 @@ def _parse_manifest(path: Path) -> CatalogEntry: install=install, post_install=str(data.get("post_install") or ""), suggest=suggest, - manifest_path=path, - ) + manifest_path=path) # Populated by list_catalog(); inspected by the picker / catalog UIs so the user gets actionable @@ -325,9 +313,6 @@ def get_entry(name: str) -> Optional[CatalogEntry]: return next((e for e in list_catalog() if e.name == name), None) -# ─── Status helpers ────────────────────────────────────────────────────────── - - def installed_servers() -> Dict[str, dict]: """Return current ``mcp_servers`` block from config.yaml.""" servers = load_config().get("mcp_servers") or {} @@ -366,9 +351,6 @@ def remove_server(name: str) -> bool: return True -# ─── Install ───────────────────────────────────────────────────────────────── - - def _say(msg: str, colour: str = Colors.GREEN) -> None: print(color(msg, colour)) @@ -533,8 +515,7 @@ def _apply_tool_selection( entry: CatalogEntry, *, prior_selection: Optional[List[str]], - prior_exclude: Optional[List[str]] = None, -) -> None: + prior_exclude: Optional[List[str]] = None) -> None: """Probe the server and let the user pick which tools to enable. Probe-success: curses checklist; pre-check priority *prior_selection* (reinstall) > manifest @@ -557,8 +538,7 @@ def _apply_tool_selection( _write_tools_filter(name, "exclude", entry.tools.default_excluded) _say( f" Applied manifest exclude list ({len(entry.tools.default_excluded)} entries); " - f"everything else stays enabled. {edit_hint}" - ) + f"everything else stays enabled. {edit_hint}") return _say(f" Probing '{name}' for available tools...", Colors.CYAN) @@ -573,8 +553,7 @@ def _apply_tool_selection( _write_tools_filter(name, "include", prior_selection) msg = ( f" Couldn't probe server. Kept your previous tool selection " - f"({len(prior_selection)} tools). {refine_hint}" - ) + f"({len(prior_selection)} tools). {refine_hint}") elif prior_exclude is not None: _write_tools_filter(name, "exclude", prior_exclude) msg = f" Couldn't probe server. Kept your existing exclude list ({len(prior_exclude)} entries)." @@ -582,14 +561,12 @@ def _apply_tool_selection( _write_tools_filter(name, "include", manifest_default) msg = ( f" Couldn't probe server. Applied manifest default " - f"({len(manifest_default)} tools). {refine_hint}" - ) + f"({len(manifest_default)} tools). {refine_hint}") else: _write_tools_filter(name, "include", None) msg = ( " Couldn't probe server; installed with no tool filter (all tools enabled when " - f"reachable). Run {configure_hint} after first connect to prune." - ) + f"reachable). Run {configure_hint} after first connect to prune.") _say(msg, Colors.YELLOW) return @@ -604,7 +581,9 @@ def _apply_tool_selection( import sys as _sys if not _sys.stdin.isatty(): preferred = prior_selection if prior_selection is not None else (entry.tools.default_enabled or None) - _write_tools_filter(name, "include", None if preferred is None else [n for n in preferred if n in tool_names]) + _write_tools_filter( + name, "include", None if preferred is None else [n for n in preferred if n in tool_names] + ) return pre_set = {n for n in (prior_selection or entry.tools.default_enabled or tool_names) if n in tool_names} @@ -615,8 +594,7 @@ def _apply_tool_selection( labels = [f"{n} — {(d[:60] + '...') if len(d) > 60 else d}" for n, d in probed] chosen_indices = curses_checklist( - f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices, - ) + f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices) if not chosen_indices: # Everything unchecked: write an empty include so the server is installed but contributes # nothing until reconfigured. @@ -629,8 +607,7 @@ def _apply_tool_selection( _write_tools_filter(name, "include", None) _say( f" ✓ All {len(probed)} tools enabled (no filter — new tools " - "the server adds later will be auto-enabled)." - ) + "the server adds later will be auto-enabled).") return chosen_names = [tool_names[i] for i in sorted(chosen_indices)] _write_tools_filter(name, "include", chosen_names) @@ -665,14 +642,12 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: f" This MCP uses {entry.auth.provider} OAuth. Run " f"`hermes auth {entry.auth.provider}` if you have not " "already authenticated.", - Colors.YELLOW, - ) + Colors.YELLOW) elif entry.auth.type == "oauth": _say( " This MCP uses native OAuth 2.1; tokens will be acquired " "on first connection (browser flow).", - Colors.DIM, - ) + Colors.DIM) # Read prior user selection BEFORE overwriting the entry so a reinstall preserves it. prior_selection = _read_prior_tool_list(entry.name, "include") @@ -692,8 +667,7 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: _say( f" ✓ Installed '{entry.name}' " f"({'enabled' if enable else 'disabled'}). " - f"Start a new Hermes session to load its tools." - ) + f"Start a new Hermes session to load its tools.") if entry.post_install: print() for line in entry.post_install.strip().splitlines(): @@ -702,7 +676,7 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool: - """Remove a catalog-installed MCP from config and (optionally) its clone dir. True if anything was removed.""" + """Remove a catalog-installed MCP from config and (optionally) its clone dir. True if anything removed.""" removed = remove_server(name) if purge_install_dir: clone = _install_root() / name diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index c0119dbcd1..385b4d7fe2 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -28,19 +28,11 @@ _MCP_PRESETS: Dict[str, Dict[str, Any]] = { "codex": {"command": "codex", "args": ["mcp-server"]}, } -# ─── UI Helpers ─────────────────────────────────────────────────────────────── -def _info(text: str): - print(color(f" {text}", Colors.DIM)) - -def _success(text: str): - print(color(f" ✓ {text}", Colors.GREEN)) - -def _warning(text: str): - print(color(f" ⚠ {text}", Colors.YELLOW)) - -def _error(text: str): - print(color(f" ✗ {text}", Colors.RED)) +def _info(text: str): print(color(f" {text}", Colors.DIM)) +def _success(text: str): print(color(f" ✓ {text}", Colors.GREEN)) +def _warning(text: str): print(color(f" ⚠ {text}", Colors.YELLOW)) +def _error(text: str): print(color(f" ✗ {text}", Colors.RED)) def _confirm(question: str, default: bool = True) -> bool: @@ -59,8 +51,6 @@ def _print_tools(tools: List[Tuple[str, str]], width: int, desc_max: int) -> Non print(f" {color(tool_name, Colors.GREEN):{width}s} {short}") -# ─── Config Helpers ─────────────────────────────────────────────────────────── - def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]: """Return the ``mcp_servers`` dict from config, or empty dict.""" if config is None: @@ -77,8 +67,7 @@ def _tool_filters(cfg: dict) -> Tuple[Optional[list], Optional[list]]: include, exclude = tools_cfg.get("include"), tools_cfg.get("exclude") return ( include if include and isinstance(include, list) else None, - exclude if exclude and isinstance(exclude, list) else None, - ) + exclude if exclude and isinstance(exclude, list) else None) def _save_mcp_server(name: str, server_config: dict) -> bool: @@ -215,8 +204,7 @@ def _apply_mcp_preset( url: Optional[str], command: Optional[str], cmd_args: List[str], - server_config: Dict[str, Any], -) -> tuple[Optional[str], Optional[str], List[str], bool]: + server_config: Dict[str, Any]) -> tuple[Optional[str], Optional[str], List[str], bool]: """Apply a known MCP preset when transport details were omitted.""" if not preset_name: return url, command, cmd_args, False @@ -236,8 +224,6 @@ def _apply_mcp_preset( return url, command, cmd_args, True -# ─── Discovery (temporary connect) ─────────────────────────────────────────── - def _resolve_mcp_server_config(config: dict) -> dict: """Resolve ``${ENV}`` placeholders in a server config before connecting. @@ -269,8 +255,7 @@ def _probe_single_server( raise ValueError("; ".join(issues)) from tools.mcp_tool import ( - _ensure_mcp_loop, _run_on_mcp_loop, _connect_server, _stop_mcp_loop_if_idle, _parse_boolish, - ) + _ensure_mcp_loop, _run_on_mcp_loop, _connect_server, _stop_mcp_loop_if_idle, _parse_boolish) config = _resolve_mcp_server_config(config) if connect_timeout is None: @@ -299,10 +284,8 @@ def _probe_single_server( details["schema_chars"] = { t.name: len(_json.dumps( - _convert_mcp_schema(name, t), separators=(",", ":"), default=str, - )) - for t in server._tools - } + _convert_mcp_schema(name, t), separators=(",", ":"), default=str)) + for t in server._tools} except Exception: # pragma: no cover — display-only extra pass # Gate capability probes like runtime registration (_select_utility_schemas): @@ -357,9 +340,9 @@ def _unwrap_exception_group(exc: BaseException) -> Exception: return exc if isinstance(exc, Exception) else RuntimeError(str(exc)) -# ─── hermes mcp add ────────────────────────────────────────────────────────── - -def _configure_http_auth(name: str, url: str, auth_type: Optional[str], server_config: Dict[str, Any]) -> bool: +def _configure_http_auth( + name: str, url: str, auth_type: Optional[str], server_config: Dict[str, Any] +) -> bool: """OAuth or Bearer-token setup for an HTTP server. False when the user cancelled.""" print() if auth_type == "oauth": @@ -383,7 +366,8 @@ def _configure_http_auth(name: str, url: str, auth_type: Optional[str], server_c return True _info(f"Connecting to {url}") - if _confirm("Does this server require authentication?", default=True) and (auth_type == "header" or not auth_type): + needs_auth = _confirm("Does this server require authentication?", default=True) + if needs_auth and (auth_type == "header" or not auth_type): env_key = _env_key_for_server(name) if get_env_value(env_key): _success(f"{env_key}: already configured") @@ -405,7 +389,9 @@ def _choose_tools(name: str, tools: List[Tuple[str, str]], server_config: Dict[s _print_tools(tools, 40, 60) print() try: - choice = input(color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)).strip().lower() + choice = input( + color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW) + ).strip().lower() except (KeyboardInterrupt, EOFError): print() _info("Cancelled.") @@ -417,7 +403,8 @@ def _choose_tools(name: str, tools: List[Tuple[str, str]], server_config: Dict[s return len(tools) from hermes_cli.curses_ui import curses_checklist - chosen = curses_checklist(f"Select tools for '{name}'", [f"{t[0]} — {t[1]}" for t in tools], set(range(len(tools)))) + labels = [f"{t[0]} — {t[1]}" for t in tools] + chosen = curses_checklist(f"Select tools for '{name}'", labels, set(range(len(tools)))) if not chosen: _info("No tools selected — server not saved.") return None @@ -443,8 +430,7 @@ def cmd_mcp_add(args): explicit_env = _parse_env_assignments(getattr(args, "env", None)) url, command, cmd_args, _preset_applied = _apply_mcp_preset( name, preset_name=getattr(args, "preset", None), url=url, command=command, - cmd_args=list(cmd_args), server_config=server_config, - ) + cmd_args=list(cmd_args), server_config=server_config) except ValueError as exc: _error(str(exc)) return @@ -460,7 +446,9 @@ def cmd_mcp_add(args): _info(' hermes mcp add myserver --preset mypreset') return - if name in _get_mcp_servers() and not _confirm(f"Server '{name}' already exists. Overwrite?", default=False): + if name in _get_mcp_servers() and not _confirm( + f"Server '{name}' already exists. Overwrite?", default=False + ): _info("Cancelled.") return @@ -505,12 +493,12 @@ def cmd_mcp_add(args): server_config["enabled"] = True if _save_mcp_server(name, server_config): print() - _success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{len(tools)} tools enabled)") + _success( + f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{len(tools)} tools enabled)" + ) _info("Start a new session to use these tools.") -# ─── hermes mcp remove ─────────────────────────────────────────────────────── - def cmd_mcp_remove(args): """Remove an MCP server from config.""" name = args.name @@ -531,8 +519,6 @@ def cmd_mcp_remove(args): pass -# ─── hermes mcp list ────────────────────────────────────────────────────────── - def cmd_mcp_list(args=None): """List all configured MCP servers.""" servers = _get_mcp_servers() @@ -566,7 +552,12 @@ def cmd_mcp_list(args=None): transport = transport[:25] + "..." include, exclude = _tool_filters(cfg) - tools_str = f"{len(include)} selected" if include else f"-{len(exclude)} excluded" if exclude else "all" + if include: + tools_str = f"{len(include)} selected" + elif exclude: + tools_str = f"-{len(exclude)} excluded" + else: + tools_str = "all" enabled = cfg.get("enabled", True) if isinstance(enabled, str): @@ -576,8 +567,6 @@ def cmd_mcp_list(args=None): print() -# ─── hermes mcp test ────────────────────────────────────────────────────────── - def cmd_mcp_test(args): """Test connection to an MCP server.""" name = args.name @@ -618,8 +607,6 @@ def cmd_mcp_test(args): print() -# ─── hermes mcp login ──────────────────────────────────────────────────────── - def _reauth_oauth_server(name: str, server_config: dict) -> bool: """Force a fresh OAuth flow for one server. Returns True on success. @@ -657,7 +644,9 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: except (TypeError, ValueError): _login_connect_timeout = 0.0 with force_interactive_oauth(): - tools = _probe_single_server(name, server_config, connect_timeout=max(_login_connect_timeout, 315.0)) + tools = _probe_single_server( + name, server_config, connect_timeout=max(_login_connect_timeout, 315.0) + ) # A clean probe is NOT proof of authentication: some servers (e.g. Google Drive) serve # initialize + tools/list without auth, so the flow may have failed (e.g. DCR 400 for # providers without RFC 7591) while the probe still lists tools. Verify a token landed. @@ -667,12 +656,12 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: _info( "Some providers (e.g. Google Drive, Atlassian) do not support " "automatic client registration. For those you must create an " - "OAuth client yourself and add its credentials to config.yaml:" - ) + "OAuth client yourself and add its credentials to config.yaml:") print() for line in ( "mcp_servers:", f" {name}:", f" url: {url}", " auth: oauth", " oauth:", - ' client_id: ""', ' client_secret: ""', + ' client_id: ""', + ' client_secret: ""', ): print(color(f" {line}", Colors.DIM)) print() @@ -732,9 +721,9 @@ def cmd_mcp_reauth(args): _reauth_oauth_server(name, cfg) -# ─── hermes mcp configure ──────────────────────────────────────────────────── - -def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chosen: set, matches_name_filter) -> List[str]: +def _rebuild_exclude_list( + name: str, exclude: list, tool_names: List[str], chosen: set, matches_name_filter +) -> List[str]: """New ``tools.exclude`` for an exclude-mode entry after a checklist edit. Stays in exclude mode rather than demoting the user's dynamic filter to a frozen include list: @@ -747,11 +736,12 @@ def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chose unchecked = {tool_names[i] for i in range(len(tool_names)) if i not in chosen} checked = {tool_names[i] for i in chosen} new_literals = (literal_entries - checked) | { - tn for tn in unchecked if not matches_name_filter(tn, set(old_exclude)) - } + tn for tn in unchecked if not matches_name_filter(tn, set(old_exclude))} # A re-checked tool still matched by a kept glob can't be enabled without dropping the glob — # surface that instead of silently ignoring the click or silently freezing the config. - glob_shadowed = sorted(tn for tn in checked if glob_entries and matches_name_filter(tn, set(glob_entries))) + glob_shadowed = sorted( + tn for tn in checked if glob_entries and matches_name_filter(tn, set(glob_entries)) + ) if glob_shadowed: _warning( f"{len(glob_shadowed)} re-enabled tool(s) still match glob " @@ -759,8 +749,7 @@ def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chose f"{', '.join(glob_shadowed[:5])}" f"{' ...' if len(glob_shadowed) > 5 else ''}. Remove the " f"pattern from mcp_servers.{name}.tools.exclude in " - "config.yaml to enable them." - ) + "config.yaml to enable them.") return glob_entries + sorted(new_literals) @@ -797,12 +786,11 @@ def cmd_mcp_configure(args): def matches_name_filter(tool_name, patterns): return tool_name in patterns - if include: - include_set = {str(p) for p in include} - pre_selected = {i for i, tn in enumerate(tool_names) if matches_name_filter(tn, include_set)} - elif exclude: - exclude_set = {str(p) for p in exclude} - pre_selected = {i for i, tn in enumerate(tool_names) if not matches_name_filter(tn, exclude_set)} + patterns = {str(p) for p in (include or exclude or [])} + if patterns: + pre_selected = { + i for i, tn in enumerate(tool_names) if matches_name_filter(tn, patterns) == bool(include) + } else: pre_selected = set(range(total)) @@ -811,7 +799,8 @@ def cmd_mcp_configure(args): from hermes_cli.curses_ui import curses_checklist - chosen = curses_checklist(f"Select tools for '{name}'", [f"{t[0]} — {t[1]}" for t in all_tools], pre_selected) + labels = [f"{t[0]} — {t[1]}" for t in all_tools] + chosen = curses_checklist(f"Select tools for '{name}'", labels, pre_selected) if chosen == pre_selected: _info("No changes made.") return @@ -841,8 +830,6 @@ def cmd_mcp_configure(args): _info("Start a new session for changes to take effect.") -# ─── Dispatcher ─────────────────────────────────────────────────────────────── - _MCP_USAGE = ( "hermes mcp Open the catalog picker (default)", "hermes mcp catalog List Nous-approved MCPs", @@ -867,21 +854,19 @@ def mcp_command(args): from mcp_serve import run_mcp_server run_mcp_server(verbose=getattr(args, "verbose", False)) return - # Catalog subcommands live in mcp_picker / mcp_catalog; import lazily to keep this module cheap. - if action == "picker": - from hermes_cli.mcp_picker import run_picker - run_picker() - return - if action == "catalog": - from hermes_cli.mcp_picker import show_catalog - show_catalog() - return - if action == "install": - from hermes_cli.mcp_picker import install_by_name - import sys as _sys - rc = install_by_name(getattr(args, "identifier", "") or "") - if rc: - _sys.exit(rc) + if action in ("picker", "catalog", "install"): + # Catalog subcommands live in mcp_picker / mcp_catalog; import lazily to keep this module cheap. + from hermes_cli import mcp_picker + + if action == "picker": + mcp_picker.run_picker() + elif action == "catalog": + mcp_picker.show_catalog() + else: + import sys as _sys + rc = mcp_picker.install_by_name(getattr(args, "identifier", "") or "") + if rc: + _sys.exit(rc) return handler = { "add": cmd_mcp_add, "remove": cmd_mcp_remove, "rm": cmd_mcp_remove, "list": cmd_mcp_list, diff --git a/hermes_cli/mcp_picker.py b/hermes_cli/mcp_picker.py index 36902b6720..66477ad3d0 100644 --- a/hermes_cli/mcp_picker.py +++ b/hermes_cli/mcp_picker.py @@ -79,8 +79,7 @@ def _enable_disable(name: str, *, enable: bool) -> None: print(color( f" ✓ '{name}' {'enabled' if enable else 'disabled'}. " "Start a new Hermes session for changes to take effect.", - Colors.GREEN, - )) + Colors.GREEN)) def _configure_tools(name: str) -> None: @@ -119,8 +118,7 @@ def _uninstall(name: str) -> None: print(color( f" ✓ Uninstalled '{name}'. " "Credentials in .env preserved — delete manually if no longer needed.", - Colors.GREEN, - )) + Colors.GREEN)) else: print(color(f" '{name}' was not installed", Colors.DIM)) @@ -146,8 +144,7 @@ def _handle_row(row: _Row) -> None: ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)), ("Enable" if not enabled else "Disable", lambda: _enable_disable(row.name, enable=not is_enabled(row.name))), - ("Remove from config", lambda: _remove_custom(row.name)), - ]) + ("Remove from config", lambda: _remove_custom(row.name))]) return # Catalog row, installed + enabled print() @@ -158,8 +155,7 @@ def _handle_row(row: _Row) -> None: lambda: _enable_disable(row.name, enable=False)), ("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)), ("Reinstall (re-clone, re-prompt for credentials)", - lambda: _install(row.entry, "reinstall")), - ]) + lambda: _install(row.entry, "reinstall"))]) def _print_rows_text(rows: List[_Row]) -> None: @@ -186,8 +182,7 @@ def _print_rows_text(rows: List[_Row]) -> None: print(color( f" ⚠ '{name}' requires a newer Hermes — run `hermes update` " "to install this entry.", - Colors.YELLOW, - )) + Colors.YELLOW)) print() print() @@ -206,7 +201,8 @@ def run_picker() -> None: _print_rows_text(rows) # non-interactive: degrade to the text dump return idx = curses_single_select( - "MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", [_format_row(r) for r in rows], + "MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", + [_format_row(r) for r in rows], ) if idx is None: return @@ -222,7 +218,6 @@ def install_by_name(identifier: str) -> int: print(color( f" ✗ '{identifier}' is not in the catalog. " "Run `hermes mcp catalog` to see available entries.", - Colors.RED, - )) + Colors.RED)) return 1 return 0 if _install(entry, "install") else 1 diff --git a/hermes_cli/mcp_security.py b/hermes_cli/mcp_security.py index ca4d43cdeb..01bf1a9686 100644 --- a/hermes_cli/mcp_security.py +++ b/hermes_cli/mcp_security.py @@ -103,8 +103,7 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]: # One IOC is enough to refuse; don't leak the full match list. issues.append( f"MCP server '{name}' contains a known hermes-0day " - f"indicator-of-compromise ('{ioc}')" - ) + f"indicator-of-compromise ('{ioc}')") return issues command = entry.get("command") @@ -117,8 +116,7 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]: if _EGRESS_PATTERN.search(script): issue = ( f"MCP server '{name}' uses shell interpreter '{command}' with " - f"network egress in args" - ) + f"network egress in args") if _EXFIL_HINT_PATTERN.search(script): issue += " and exfiltration-shaped arguments" issues.append(issue) @@ -127,6 +125,5 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]: f"MCP server '{name}' uses shell interpreter '{command}' to write " f"to an OS persistence surface (SSH keys / PAM / sudoers / cron / " f"shell rc) — this is the hermes-0day backdoor shape, not a real " - f"MCP server" - ) + f"MCP server") return issues diff --git a/hermes_cli/mcp_startup.py b/hermes_cli/mcp_startup.py index f88f784032..efb25bdd97 100644 --- a/hermes_cli/mcp_startup.py +++ b/hermes_cli/mcp_startup.py @@ -55,8 +55,7 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None: return logger.warning( "Background MCP discovery previously exited with no connected " - "servers; retrying discovery thread" - ) + "servers; retrying discovery thread") _mcp_discovery_started = False _mcp_discovery_thread = None @@ -201,8 +200,7 @@ def ensure_mcp_discovery_before_agent_build( logger, timeout: "float | None" = None, single_query: bool = False, - thread_name: str = "cli-mcp-discovery", -) -> None: + thread_name: str = "cli-mcp-discovery") -> None: """Give configured MCP tools a bounded chance to register before AIAgent. Non-interactive first turns (``chat -q``, ``hermes -z``) can construct ``AIAgent`` before any diff --git a/hermes_cli/memory_oauth.py b/hermes_cli/memory_oauth.py index 4ab1666c05..a8ad018e4b 100644 --- a/hermes_cli/memory_oauth.py +++ b/hermes_cli/memory_oauth.py @@ -49,7 +49,7 @@ def _scope_to_profile(profile: Optional[str]): @router.post("/{provider}/oauth/start") async def start_memory_oauth(provider: str, profile: Optional[str] = None): - """Begin a provider's zero-CLI OAuth flow (browser + loopback listener). Returns immediately; poll status.""" + """Begin a provider's zero-CLI OAuth flow (browser + loopback listener); returns immediately, poll status.""" flow = _resolve_flow(provider) try: # The flow resolves its config path eagerly inside this scope; its worker thread outlives it. diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 0a6783879d..96d6da338a 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -17,8 +17,7 @@ _IMPORT_NAMES = { "honcho-ai": "honcho", "mem0ai": "mem0", "hindsight-client": "hindsight_client", - "hindsight-all": "hindsight", -} + "hindsight-all": "hindsight"} def _provider_pip_dependencies(provider_name: str, declared: list) -> list: @@ -41,16 +40,8 @@ def _provider_pip_dependencies(provider_name: str, declared: list) -> list: return deps -# --------------------------------------------------------------------------- -# Curses-based interactive picker (same pattern as hermes tools) -# --------------------------------------------------------------------------- - def _curses_select( - title: str, - items: list[tuple[str, str]], - default: int = 0, - *, - cancel_returns: int | None = None, + title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None ) -> int: """Interactive single-select with arrow keys.""" from hermes_cli.curses_ui import curses_radiolist @@ -87,10 +78,6 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str return val or (default or "") -# --------------------------------------------------------------------------- -# Provider discovery -# --------------------------------------------------------------------------- - def _install_dependencies(provider_name: str, *, force: bool = False) -> None: """Install pip dependencies declared in ``plugin.yaml``. @@ -207,10 +194,6 @@ def _find_provider(providers: list, provider_name: str): return next((p for p in providers if p[0] == provider_name), None) -# --------------------------------------------------------------------------- -# Setup wizard -# --------------------------------------------------------------------------- - def _post_setup_hook(provider, config: dict) -> bool: """Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns config, connection test and activation), so the caller must stop.""" @@ -271,7 +254,9 @@ def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_wr if choices and not is_secret: current = provider_config.get(key, default) current_idx = choices.index(current) if current and current in choices else 0 - sel = _curses_select(f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED) + sel = _curses_select( + f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED + ) if sel == _CANCELLED: _print_cancelled_setup() return False @@ -360,9 +345,7 @@ def cmd_setup(args) -> None: def _write_env_vars( - env_writes: dict, - hermes_home: str | os.PathLike[str] | None = None, -) -> None: + env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None: """Persist memory-provider env vars through the canonical ``.env`` writer. ``save_env_value`` applies the shared gate (name regex, ``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME`` @@ -385,10 +368,6 @@ def _write_env_vars( reset_hermes_home_override(token) -# --------------------------------------------------------------------------- -# Status -# --------------------------------------------------------------------------- - def _mark(enabled) -> str: return "enabled ✓" if enabled else "disabled ✗" @@ -466,10 +445,6 @@ def cmd_status(args) -> None: print() -# --------------------------------------------------------------------------- -# Router -# --------------------------------------------------------------------------- - def memory_command(args) -> None: """Route memory subcommands.""" if getattr(args, "memory_command", None) == "setup": diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index e6351dd881..5f4e24df61 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -60,7 +60,9 @@ def _safe_copy(payload: Any) -> Any: return dict(payload) if isinstance(payload, dict) else payload -def _apply_request_chain(kind: str, payload_key: str, trace: List[Dict[str, Any]], **kwargs: Any) -> Dict[str, Any]: +def _apply_request_chain( + kind: str, payload_key: str, trace: List[Dict[str, Any]], **kwargs: Any +) -> Dict[str, Any]: """Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``.""" from hermes_cli.plugins import invoke_middleware @@ -75,8 +77,7 @@ def _apply_request_chain(kind: str, payload_key: str, trace: List[Dict[str, Any] entry = { key: value for key in ("source", "reason", "name") - if isinstance(value := result.get(key), str) and value - } + if isinstance(value := result.get(key), str) and value} trace.append(entry or {"source": "plugin"}) return current @@ -92,14 +93,15 @@ def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> Req trace: List[Dict[str, Any]] = [] current_request = _apply_request_chain( LLM_REQUEST_MIDDLEWARE, "request", trace, - request=_safe_copy(original_request), original_request=original_request, **context, - ) + request=_safe_copy(original_request), original_request=original_request, **context) return RequestMiddlewareResult( payload=current_request, original_payload=original_request, changed=bool(trace), trace=trace, ) -def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **context: Any) -> RequestMiddlewareResult: +def apply_tool_request_middleware( + tool_name: str, args: Dict[str, Any], **context: Any +) -> RequestMiddlewareResult: """Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool arguments before hooks, guardrails, approvals, and execution see them.""" original_args = _safe_copy(args) @@ -112,8 +114,7 @@ def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **contex from agent import relay_runtime relay_args = relay_runtime.apply_tool_request_intercepts( - session_id=session_id, tool_name=tool_name, args=current_args, - ) + session_id=session_id, tool_name=tool_name, args=current_args) if relay_args != current_args: current_args = _safe_copy(relay_args) trace.append({"source": "nemo_relay"}) @@ -122,25 +123,22 @@ def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **contex if not has_middleware(TOOL_REQUEST_MIDDLEWARE): return RequestMiddlewareResult( - payload=args if not trace else current_args, original_payload=args, changed=bool(trace), trace=trace, + payload=args if not trace else current_args, original_payload=args, + changed=bool(trace), trace=trace, ) current_args = _apply_request_chain( TOOL_REQUEST_MIDDLEWARE, "args", trace, - tool_name=tool_name, args=current_args, original_args=original_args, **context, - ) + tool_name=tool_name, args=current_args, original_args=original_args, **context) return RequestMiddlewareResult( - payload=current_args, original_payload=original_args, changed=bool(trace), trace=trace, - ) + payload=current_args, original_payload=original_args, changed=bool(trace), trace=trace) def run_llm_execution_middleware( - request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any, -) -> Any: + request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any: """Run provider execution through registered LLM execution middleware.""" return _run_execution_chain( LLM_EXECUTION_MIDDLEWARE, next_call, - request=request, original_request=context.pop("original_request", request), **context, - ) + request=request, original_request=context.pop("original_request", request), **context) def run_tool_execution_middleware( @@ -149,8 +147,7 @@ def run_tool_execution_middleware( """Run tool execution through registered tool execution middleware.""" return _run_execution_chain( TOOL_EXECUTION_MIDDLEWARE, next_call, - tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context, - ) + tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context) class _DownstreamExecutionError(Exception): @@ -187,8 +184,7 @@ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwarg raise RuntimeError( f"Middleware '{kind}' callback " f"{getattr(callback, '__name__', repr(callback))} called " - "next_call() more than once; downstream execution is single-use" - ) + "next_call() more than once; downstream execution is single-use") next_called = True try: next_result = call_at(index + 1, payload if next_payload is None else next_payload) @@ -207,8 +203,7 @@ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwarg except Exception as exc: logger.warning( "Middleware '%s' callback %s raised: %s", - kind, getattr(callback, "__name__", repr(callback)), exc, - ) + kind, getattr(callback, "__name__", repr(callback)), exc) if next_succeeded: return next_result if next_called: