From 8cfc5053ee872f5b72f921dcc60816cba379ea9e Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Tue, 15 Sep 2026 12:31:41 -0700 Subject: [PATCH 1/3] fix(mcp): report lazily registered servers as lazy, not configured or failed A `lazy: true` MCP server registers its tools from the schema cache and spawns on first use. Three consumers still equated "alive" with a live session, so a healthy all-lazy startup was reported as a total failure: - `get_mcp_status()` fell through to `status: configured, tools: 0` for a lazily registered server. It now reports `lazy` with the cached tool count (`connected: False`); an in-flight or failed first-use connect still outranks it because the error is the actionable part. - `discover_mcp_tools()`'s summary counted every name absent from `_servers` as failed, logging `MCP: 0 tool(s) from 0 server(s) (2 failed)` right after registering every cached tool, and re-announced the same "failure" on every repeat discovery. Lazy servers are now reported as `(N lazy, not spawned yet)` and an already-lazy server is not re-announced. - `hermes_cli/mcp_startup.py` judged a discovery run by `connected` at two sites, so every startup logged `Background MCP discovery completed with zero connected servers` and every later call re-spawned the discovery thread as a retry. One predicate, `_discovery_registered_servers`, treats a lazy registration as a usable outcome at both sites. - `hermes_cli/banner.py` rendered the unknown `lazy` status through the red "could not connect" line; it now shows the cached tool count with `(lazy, starts on first use)`. Ported from #100648 (core hunks only; the toolsets-filter predicate branch, the Ink TUI component extraction and 13 tests were not ported). Fixes #111717 --- hermes_cli/banner.py | 5 +++++ hermes_cli/mcp_startup.py | 13 ++++++++++++- tools/mcp_tool_discovery.py | 38 +++++++++++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1ffce168d8416..8e33e7b0b1bf5 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -752,6 +752,11 @@ def _mcp_server_line(srv: dict, *, dim: str, text: str) -> str: name, transport = srv["name"], srv["transport"] if srv["connected"]: return f"[dim {dim}]{name}[/] [{text}]({transport})[/] [dim {dim}]—[/] [{text}]{srv['tools']} tool(s)[/]" + # Needs srv['tools'], so it cannot live in the suffix dict below. A registered but unspawned + # server has callable tools; falling through to the red "failed" line misreports a working setup. + if srv.get("status") == "lazy" and not srv.get("disabled"): + return (f"[dim {dim}]{name}[/] [{text}]({transport})[/] [dim {dim}]—[/] " + f"[{text}]{srv['tools']} tool(s)[/] [dim {dim}](lazy, starts on first use)[/]") status = "disabled" if srv.get("disabled") else srv.get("status") suffix = {"disabled": f"[dim {dim}]— disabled[/]", "connecting": "[yellow]— connecting[/]", "configured": f"[dim {dim}]— configured[/]"}.get(status) diff --git a/hermes_cli/mcp_startup.py b/hermes_cli/mcp_startup.py index 42f781a89a21e..cfe1b6ac6d809 100644 --- a/hermes_cli/mcp_startup.py +++ b/hermes_cli/mcp_startup.py @@ -65,10 +65,21 @@ def _has_configured_mcp_servers() -> bool: return True # conservative: still try discovery in the background; startup can't block +def _discovery_registered_servers(status) -> bool: + """True when a discovery run left servers usable: a live session OR a lazy registration. + + A ``lazy: true`` server never connects until first use, so an all-lazy config (the + memory-saving setup the feature exists for) looked like a run that achieved nothing: + the zero-connected warning fired on every startup and the retry path re-ran discovery + on every later call (#111717). + """ + return any(entry.get("connected") or entry.get("status") == "lazy" for entry in (status or [])) + + def _any_mcp_connected() -> bool: from tools.mcp_tool_discovery import get_mcp_status - return any(entry.get("connected") for entry in (get_mcp_status() or [])) + return _discovery_registered_servers(get_mcp_status() or []) def start_background_mcp_discovery(*, logger, thread_name: str) -> None: diff --git a/tools/mcp_tool_discovery.py b/tools/mcp_tool_discovery.py index 8a0429c2a7d4b..cd65bccac985c 100644 --- a/tools/mcp_tool_discovery.py +++ b/tools/mcp_tool_discovery.py @@ -16,7 +16,7 @@ from tools import mcp_tool_loop as _loop from tools import mcp_tool_registration as _registration from tools.mcp_tool_schema import MCP_TOOL_NAME_PREFIX -from tools.mcp_tool_scope import _key_name, _key_scope, _resolve_server_key, _server_key +from tools.mcp_tool_scope import _key_name, _key_scope, _key_visible_in_scope, _resolve_server_key, _server_key logger = logging.getLogger("tools.mcp_tool") @@ -367,10 +367,12 @@ def _connected_summary(names, *, lazy_tools: int = 0, lazy_servers: int = 0) -> def _log_summary(prefix: str, names, **lazy) -> None: """Log `` N tool(s) from M server(s) (K failed)`` when anything happened.""" new_tool_count, connected_count, failed = _connected_summary(names, **lazy) - if new_tool_count or failed: + if new_tool_count or failed or lazy.get("lazy_servers"): summary = f"{prefix} {new_tool_count} tool(s) from {connected_count} server(s)" if failed: summary += f" ({failed} failed)" + if lazy.get("lazy_servers"): + summary += f" ({lazy['lazy_servers']} lazy, not spawned yet)" logger.info(summary) @@ -468,9 +470,19 @@ def discover_mcp_tools(allowed_mcp_names: Optional[List[str]] = None) -> List[st new_server_names = [name for name, cfg in servers.items() if keys[name] not in _core._servers and keys[name] not in _core._server_connecting and _enabled(cfg)] + prior_lazy = set(_core._lazy_server_configs) tool_names = register_mcp_servers(servers) if new_server_names: - _log_summary(" MCP:", new_server_names) + # A lazily registered server never connected, so it must not be counted as failed + # (the old summary read "N failed" for a healthy all-lazy config, #111717). Reporting + # it separately also keeps an already-lazy server from being re-announced on a + # repeat discovery. Lazy state is keyed by resolved server key, not by name. + with _core._lock: + lazy_now = [n for n in new_server_names if keys[n] in _core._lazy_server_configs] + newly_lazy = [n for n in lazy_now if keys[n] not in prior_lazy] + lazy_tools = sum(len(_core._lazy_server_tool_names.get(keys[n], [])) for n in newly_lazy) + _log_summary(" MCP:", [n for n in new_server_names if n not in lazy_now], + lazy_tools=lazy_tools, lazy_servers=len(newly_lazy)) return tool_names finally: if cookie not in (None, _core._LOCK_UNAVAILABLE): @@ -535,8 +547,12 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool: def get_mcp_status(configured: Optional[Dict[str, dict]] = None, *, include_runtime: bool = True) -> List[dict]: """Per-server status dicts for banner/TUI: name, transport, tools, connected, disabled, - status (connected / disabled / connecting / failed / configured) and error for failed. - Reads cached runtime state only; never connects.""" + status (connected / disabled / connecting / failed / lazy / configured) and error for failed. + Reads cached runtime state only; never connects. + + ``lazy`` is a registered-but-not-spawned server (``lazy: true``, tools from the schema + cache): its tools are callable and the process starts on first use. Reporting it as + ``configured`` (never registered) misreads a working setup (#111717).""" configured = _config._load_mcp_config() if configured is None else dict(configured) if not configured: return [] @@ -551,14 +567,22 @@ def visible(key) -> bool: active_servers = {_key_name(k): s for k, s in _core._servers.items() if visible(k)} connecting = {_key_name(k) for k in _core._server_connecting if visible(k)} connect_errors = {_key_name(k): e for k, e in _core._server_connect_errors.items() if visible(k)} + # A lazy registration is not a live connection: ``_server_visible_in_scope`` reads the + # adoption/teardown maps a lazy server never populates, so use the registration-level + # predicate ``_resolve_server_key`` already relies on for this state. + lazy_tools = {_key_name(k): len(v) for k, v in _core._lazy_server_tool_names.items() + if include_runtime and _key_visible_in_scope(k, current_scope) + and k in _core._lazy_server_configs} result: List[dict] = [] for name, cfg in configured.items(): enabled = _enabled(cfg) # evaluated unconditionally: malformed values warn even when connected server = active_servers.get(name) live = server is not None and server.session is not None + # An in-flight or failed first-use connect outranks "lazy": that server is no longer + # merely waiting to be spawned, and the error is the actionable part. status = ("connected" if live else "disabled" if not enabled else "connecting" if name in connecting - else "failed" if name in connect_errors else "configured") + else "failed" if name in connect_errors else "lazy" if name in lazy_tools else "configured") entry = {"name": name, "transport": cfg.get("transport", "http") if "url" in cfg else "stdio", "tools": 0, "connected": False, "disabled": status == "disabled", "status": status} if live: @@ -569,6 +593,8 @@ def visible(key) -> bool: entry["sampling"] = dict(server._sampling.metrics) elif status == "failed": entry["error"] = connect_errors[name] + elif status == "lazy": + entry["tools"] = lazy_tools[name] result.append(entry) return result From 8fc7f1b2b9618dc4d97ce3ecc0b41168e8379853 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:31:55 -0700 Subject: [PATCH 2/3] fix(mcp): carry the lazy status across the TUI wire, tests and docs Follow-up to the ported status fix: - `tui_gateway/contracts/tools_mcp_plugins.py::McpRuntimeStatus` is a closed wire enum; `mcp.servers.status` would raise `ContractViolation` on the new `lazy` value. Declare it and regenerate the TS/OpenRPC contract files. - `ui-tui` session panel: an unknown status fell through to the red `failed` branch; render `lazy` with its cached tool count (inline branch, no component extraction). - Two invariant tests, both red on origin/main: the real discovery path yields `status: lazy` with the cached tool count and a summary without `failed` (eager control stays `configured`, live control stays `connected`); a lazy-only run neither warns nor re-arms the startup retry, while a configured-only run still does. - Document the per-server `lazy` key (undocumented until now) in `cli-config.yaml.example`, the MCP config reference and the MCP guide. --- apps/shared/src/gateway-contract.generated.ts | 2 +- apps/shared/src/gateway-contract.openrpc.json | 1 + cli-config.yaml.example | 6 +++ tests/hermes_cli/test_mcp_startup.py | 30 ++++++++++++- tests/tools/test_mcp_lazy_start.py | 45 +++++++++++++++++++ tui_gateway/contracts/tools_mcp_plugins.py | 1 + ui-tui/src/components/branding.tsx | 5 +++ ui-tui/src/types.ts | 2 +- .../docs/reference/mcp-config-reference.md | 1 + website/docs/user-guide/features/mcp.md | 5 +++ 10 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index 8c1f85edd50bd..8262e5c1283f4 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -3533,7 +3533,7 @@ export interface McpServerRuntimeRow { disabled: boolean status: McpRuntimeStatus } -export type McpRuntimeStatus = 'connected' | 'disabled' | 'connecting' | 'failed' | 'configured' +export type McpRuntimeStatus = 'connected' | 'disabled' | 'connecting' | 'failed' | 'lazy' | 'configured' /** ``preset`` (catalog id) and/or ``config`` (url/command/args/env/headers/auth/tools); a ``bearer_token`` is written to the profile's .env, only the header template persists. */ export interface McpServersAddParams { profile?: string | null diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index a05d5905fe59c..c4480ec5facd4 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -14154,6 +14154,7 @@ "disabled", "connecting", "failed", + "lazy", "configured" ], "title": "McpRuntimeStatus", diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 115189226376b..0465877b54419 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1439,6 +1439,12 @@ platform_toolsets: # Lower it below the server's session TTL for servers that expire idle # sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle # tool calls hit an expired session and pay a slow reconnect. Floored at 5s. +# lazy: register the server's tools from the on-disk schema cache at startup +# and only spawn/connect it on the first tool call (default: false). Saves +# one child process per server in processes that rarely call tools. Needs +# one prior live connect to populate the cache; a missing or stale cache +# entry falls back to the normal eager connect. The banner and the TUI show +# such a server as "lazy" with its cached tool count until it is first used. # # mcp_servers: # time: diff --git a/tests/hermes_cli/test_mcp_startup.py b/tests/hermes_cli/test_mcp_startup.py index ebfe99701fe0f..df03dade2da11 100644 --- a/tests/hermes_cli/test_mcp_startup.py +++ b/tests/hermes_cli/test_mcp_startup.py @@ -309,7 +309,7 @@ def _retry_logger(): ) -def _install_retry_stubs(monkeypatch, *, connected: bool, calls: dict): +def _install_retry_stubs(monkeypatch, *, connected: bool, calls: dict, status: str = "configured"): monkeypatch.setitem( sys.modules, "hermes_cli.config", @@ -327,7 +327,7 @@ def _install_retry_stubs(monkeypatch, *, connected: bool, calls: dict): "tools.mcp_tool_discovery", types.SimpleNamespace( discover_mcp_tools=lambda: calls.__setitem__("mcp", calls["mcp"] + 1), - get_mcp_status=lambda: [{"connected": connected}], + get_mcp_status=lambda: [{"name": "demo", "connected": connected, "status": status}], ), ) @@ -440,3 +440,29 @@ def test_prepare_agent_startup_installs_server_filter(monkeypatch, _reset_mcp_se monkeypatch.setattr(main_mod, "_command_has_dedicated_mcp_startup", lambda args: True) main_mod._prepare_agent_startup(_agent_args(toolsets="terminal,code-mcp")) assert mcp_startup.get_mcp_server_filter() == ["terminal", "code-mcp"] + + +@pytest.mark.parametrize(("status", "retried"), [("lazy", False), ("configured", True)]) +def test_lazy_only_discovery_counts_as_usable_at_both_startup_sites(monkeypatch, status, retried): + """A finished run whose servers are all ``lazy`` (registered from the schema cache, spawned + on first use) left them usable: no zero-connected warning, and the re-entry check must not + re-spawn discovery (#111717). Control: a run that left them merely ``configured`` still warns + and is still retried (#66981).""" + calls = {"mcp": 0} + _install_retry_stubs(monkeypatch, connected=False, calls=calls, status=status) + warnings: list = [] + logger = types.SimpleNamespace(debug=lambda *_a, **_k: None, + warning=lambda msg, *a, **_k: warnings.append(msg % a if a else msg)) + + mcp_startup.start_background_mcp_discovery(logger=logger, thread_name="t") # first run + thread = mcp_startup._current_home_thread() + if thread is not None: + thread.join(timeout=5.0) + mcp_startup.start_background_mcp_discovery(logger=logger, thread_name="t") # re-entry after it finished + thread = mcp_startup._current_home_thread() + if thread is not None: + thread.join(timeout=5.0) + + assert calls["mcp"] == (2 if retried else 1) + assert any("zero connected" in w for w in warnings) is retried + assert any("retrying discovery thread" in w for w in warnings) is retried diff --git a/tests/tools/test_mcp_lazy_start.py b/tests/tools/test_mcp_lazy_start.py index b1ecc21cda5fa..adfe451d50062 100644 --- a/tests/tools/test_mcp_lazy_start.py +++ b/tests/tools/test_mcp_lazy_start.py @@ -334,3 +334,48 @@ def test_explicit_true(self): def test_explicit_false(self): assert _mcp_discovery._resolve_server_lazy("s", {"command": "npx", "lazy": False}) is False + + +class TestLazyMcpStatus: + def test_lazy_registration_reports_lazy_with_cached_tools_not_failed(self, caplog): + """A ``lazy: true`` server registered from the schema cache is a working server: status + ``lazy`` with its cached tool count (``connected`` False), and the discovery summary counts + it as a lazy server, never as failed (#111717). Controls: an unregistered eager server stays + ``configured``; a live one stays ``connected``.""" + import logging + + from tools import mcp_tool_config as _mcp_config + from tools import mcp_tool_loop as _mcp_loop + from tools.mcp_tool_scope import _server_key + + servers = _lazy_config() + controls = {"eager": {"command": "/nonexistent/eager"}, "live": {"command": "/nonexistent/live"}} + live = SimpleNamespace(session=object(), _registered_tool_names=["l1", "l2"], _sampling=None, _tools=[]) + cached = ["mcp_playwright_browser_navigate", "mcp_playwright_browser_click"] + + def _fake_register(name, cfg, entry): + key = _server_key(name) + mcp._lazy_server_configs[key] = dict(cfg) + mcp._lazy_server_tool_names[key] = list(cached) + return list(cached) + + with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch.object(_mcp_config, "_load_mcp_config", return_value=dict(servers)), \ + patch.object(_mcp_loop, "_try_acquire_mcp_discovery_lock", return_value=mcp._LOCK_UNAVAILABLE), \ + patch("tools.mcp_schema_cache.config_fingerprint", return_value="abc"), \ + patch("tools.mcp_schema_cache.get_cached_entry", return_value=_fake_cache_entry()), \ + patch("tools.mcp_tool_registration._register_from_cache_sync", side_effect=_fake_register), \ + patch("tools.mcp_tool_discovery._discover_and_register_server", new_callable=AsyncMock), \ + patch("tools.mcp_tool_loop._ensure_mcp_loop"), patch("tools.mcp_tool_loop._run_on_mcp_loop"), \ + caplog.at_level(logging.INFO, logger="tools.mcp_tool"): + _mcp_discovery.discover_mcp_tools() + mcp._servers[_server_key("live")] = live + status = {e["name"]: e for e in _mcp_discovery.get_mcp_status({**servers, **controls})} + + summaries = [r.getMessage() for r in caplog.records if "tool(s) from" in r.getMessage()] + assert summaries and all("failed" not in m for m in summaries), summaries + assert any("1 lazy, not spawned yet" in m for m in summaries), summaries + assert (status["playwright"]["status"], status["playwright"]["tools"], + status["playwright"]["connected"]) == ("lazy", len(cached), False) + assert status["eager"]["status"] == "configured" and status["eager"]["tools"] == 0 + assert status["live"]["status"] == "connected" and status["live"]["tools"] == 2 diff --git a/tui_gateway/contracts/tools_mcp_plugins.py b/tui_gateway/contracts/tools_mcp_plugins.py index 7e6ef383e8e1e..071b5929277ca 100644 --- a/tui_gateway/contracts/tools_mcp_plugins.py +++ b/tui_gateway/contracts/tools_mcp_plugins.py @@ -381,6 +381,7 @@ class McpRuntimeStatus(WireEnum): disabled = "disabled" connecting = "connecting" failed = "failed" + lazy = "lazy" configured = "configured" diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 2d9d93fe1d3ee..d82375778a45e 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -324,6 +324,11 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { disabled ) : s.status === 'connecting' ? ( connecting + ) : s.status === 'lazy' ? ( + // Registered from the schema cache, process not spawned yet: its tools are callable. + + {s.tools} tool{s.tools === 1 ? '' : 's'} (lazy) + ) : s.status === 'configured' ? ( configured ) : ( diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index ac0caa5f3f724..5e4f42807b9fd 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -176,7 +176,7 @@ export type SectionVisibility = Partial> export interface McpServerStatus { connected: boolean disabled?: boolean - status?: 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed' + status?: 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed' | 'lazy' name: string tools: number transport: string diff --git a/website/docs/reference/mcp-config-reference.md b/website/docs/reference/mcp-config-reference.md index 21ca8ec2b08d9..cf44f0a01e93e 100644 --- a/website/docs/reference/mcp-config-reference.md +++ b/website/docs/reference/mcp-config-reference.md @@ -61,6 +61,7 @@ mcp_servers: | `skip_preflight` | bool | HTTP | Bypass the fail-fast content-type probe for valid Streamable HTTP endpoints whose HEAD/GET answers a non-MCP content type (default: `false`) | | `transport` | string | HTTP | Set to `sse` to use the SSE transport instead of Streamable HTTP | | `keepalive_interval` | number | both | Liveness ping cadence in seconds (default: `180`, floored at 5s). Set below the server's session TTL for servers that GC idle sessions quickly | +| `lazy` | bool | both | Register the server's tools from the on-disk schema cache at startup and only spawn/connect it on the first tool call (default: `false`). Needs one prior live connect to fill the cache; a missing or stale entry falls back to the normal eager connect. Status surfaces show the server as `lazy` with its cached tool count until first use | | `idle_timeout_seconds` | number | stdio | Optional stdio server recycle after idle time (`0` disables). May also live under a `lifecycle:` mapping | | `max_lifetime_seconds` | number | stdio | Optional stdio server recycle after age (`0` disables). May also live under a `lifecycle:` mapping | | `tools` | mapping | both | Filtering and utility-tool policy | diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index 0421b802be5fe..06f2a82f59ade 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -392,6 +392,7 @@ Hermes reads MCP config from `~/.hermes/config.yaml` under `mcp_servers`. | `identity_header` | mapping | Optional per-user identity header for HTTP/SSE servers — `{name, value_from: static\|profile, value}` | | `timeout` | number | Tool call timeout | | `connect_timeout` | number | Initial connection timeout (also bounds the MCP `initialize` handshake) | +| `lazy` | bool | If `true`, register the server's tools from the schema cache at startup and only start/connect it on the first tool call (default `false`). Needs one prior live connect to fill the cache. | | `idle_timeout_seconds` | number | Recycle a stdio server after this many seconds without a tool call (`0` = never, default). The server restarts transparently on the next tool call. | | `max_lifetime_seconds` | number | Recycle a stdio server after this total age (`0` = never, default). Restarts transparently on next use. | | `enabled` | bool | If `false`, Hermes skips the server entirely | @@ -638,6 +639,10 @@ That keeps the tool list clean. Hermes discovers MCP servers at startup and registers their tools into the normal tool registry. +### Lazy start + +A server with `lazy: true` is registered from the on-disk schema cache instead: its tools appear in the registry immediately, and the process is spawned (or the HTTP endpoint connected) on the first tool call. The cache is written on every live connect, so the first run of a new or changed server is always eager. The banner and the TUI session panel show such a server as **lazy** with its cached tool count (`3 tool(s) (lazy, starts on first use)`) — it is a working server, not a failed one — and the startup discovery summary counts it as `N lazy, not spawned yet`. + ### Dynamic Tool Discovery MCP servers can notify Hermes when their available tools change at runtime by sending a `notifications/tools/list_changed` notification. When Hermes receives this notification, it automatically re-fetches the server's tool list and updates the registry — no manual `/reload-mcp` required. From a915dbeeaa36c514e170f82517872071a2d5694d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:01:30 -0700 Subject: [PATCH 3/3] fix: drop the dead disabled guard on the lazy MCP banner line get_mcp_status reports status='disabled' (never 'lazy') for a disabled server and derives the 'disabled' flag from that same status, so the extra 'and not srv.get("disabled")' check could never change the branch. --- hermes_cli/banner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 8e33e7b0b1bf5..730e834cc84c2 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -754,7 +754,7 @@ def _mcp_server_line(srv: dict, *, dim: str, text: str) -> str: return f"[dim {dim}]{name}[/] [{text}]({transport})[/] [dim {dim}]—[/] [{text}]{srv['tools']} tool(s)[/]" # Needs srv['tools'], so it cannot live in the suffix dict below. A registered but unspawned # server has callable tools; falling through to the red "failed" line misreports a working setup. - if srv.get("status") == "lazy" and not srv.get("disabled"): + if srv.get("status") == "lazy": return (f"[dim {dim}]{name}[/] [{text}]({transport})[/] [dim {dim}]—[/] " f"[{text}]{srv['tools']} tool(s)[/] [dim {dim}](lazy, starts on first use)[/]") status = "disabled" if srv.get("disabled") else srv.get("status")