Skip to content

fix(mcp): report lazily registered servers as lazy, not configured or failed - #100648

Closed
jonpol01 wants to merge 4 commits into
NousResearch:mainfrom
jonpol01:fix/mcp-status-lazy-servers
Closed

jonpol01 wants to merge 4 commits into
NousResearch:mainfrom
jonpol01:fix/mcp-status-lazy-servers

Conversation

@jonpol01

@jonpol01 jonpol01 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Lazy MCP startup (#56832, mcp_servers.<name>.lazy: true) registers a server's tools from the schema cache and spawns the process on first use. Three consumers still decided "is this server alive?" by connected / name in _servers alone, so a healthy all-lazy startup — the memory-saving configuration — was reported as a total failure:

  1. get_mcp_status() had no branch for a lazily registered server: no session, not connecting, no error → it fell through to status: "configured", tools: 0 — the same shape as a server that never started — while its cached tools were registered and callable. hermes mcp status, the banner, and the dashboard's mcp_servers info all showed that.
  2. hermes_cli/mcp_startup.py judges a discovery run by any(entry.get("connected")), twice. With every server lazy, every startup logs Background MCP discovery completed with zero connected servers, and every later call to start_background_mcp_discovery() treats the previous run as one that connected nothing (the fix(mcp): allow background discovery retry after a run that connected nothing #66981 retry, meant for startup cancellation / OOM) and re-spawns the discovery thread.
  3. discover_mcp_tools()'s summary counted name in _servers as connected and everything else as failed, so immediately after registering every cached tool it logged MCP: 0 tool(s) from 0 server(s) (2 failed).

Change

  • tools/mcp_tool.pyget_mcp_status() reports a lazily registered server as status: "lazy" with its cached tool count (connected: False). Precedence keeps the more current state on top: connected → disabled → connecting (first-use spawn in flight) → failed (first-use spawn failed; the error is the actionable part) → lazy → configured. The discovery summary counts newly lazy servers as servers with tools (… (N lazy, not spawned yet)) instead of failures, and a server that was already lazy before the call is neither re-announced nor counted as failed.
  • hermes_cli/mcp_startup.py — one predicate, _discovery_registered_servers(status): a live session or a lazy registration means the run left servers usable. Used at both sites (re-entry retry decision, post-discovery warning). A run that really registered nothing still warns and still retries — tests pin both directions.
  • hermes_cli/banner.py — renders lazy with its tool count ((lazy, starts on first use)) instead of falling into the red failed branch that catches every unknown status.
  • cli-config.yaml.example — documents the lazy per-server key, which was not in the example at all.

Out of scope, noted for honesty: an idle-parked server (_was_parked, session dropped, tools deregistered) also reports configured; it has no current-state predicate on MCPServerTask, and its tools really are gone, so I left it.

Related Issue

Fixes #111717

Follows up #56832 (lazy startup) and #66981 (retry when a run connected nothing). #80948 (open) rewords the zero-connected warning message; this PR changes only the predicate feeding it and merges cleanly either way.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • tools/mcp_tool_discovery.pyget_mcp_status() reports lazy (scoped by server key; a first-use connect in flight or failed outranks it); discover_mcp_tools() counts lazy servers apart from failures and announces them once
  • hermes_cli/mcp_startup.py_discovery_registered_servers(): a run succeeded when a server connected, registered lazily, or -t excluded every server on purpose; used by the zero-connected warning and the fix(mcp): allow background discovery retry after a run that connected nothing #66981 retry
  • hermes_cli/banner.pylazy rendering with the cached tool count
  • ui-tui/src/components/branding.tsx, ui-tui/src/types.tsMcpServerLine extracted (typed as the wire entry SessionInfo carries) with a lazy branch; the status union gains lazy
  • cli-config.yaml.example, website/docs/user-guide/features/mcp.md — the lazy key, and how the server shows until first use
  • Tests, four, each red on the pre-fix code: tests/tools/test_mcp_lazy_start.py (a lazy registration read as working on every surface; lazy state scoped to its owner), tests/hermes_cli/test_mcp_startup.py (one table: all-lazy / filtered-out succeed, nothing-up / named-server-missing still fail), ui-tui/src/__tests__/mcpLazyStatus.test.ts (lazy row and headline)

How to Test

  1. scripts/run_tests.sh tests/tools/test_mcp_lazy_start.py tests/hermes_cli/test_mcp_startup.py and cd ui-tui && npx vitest run src/__tests__/mcpLazyStatus.test.ts
  2. Set lazy: true on every stdio server in mcp_servers, run once to warm the cache, restart. Before: banner shows the servers as — configured, the log says MCP: 0 tool(s) from 0 server(s) (N failed) and Background MCP discovery completed with zero connected servers. After: MCP: 21 tool(s) from 2 server(s) (2 lazy, not spawned yet), the banner lists each server with its cached tool count, and no zero-connected warning.

Verified on a real install (two stdio servers, both lazy, warm cache) with this patch applied in a throwaway worktree — no child process was spawned:

[log] MCP server 'linear' (lazy): registered 17 tool(s) from schema cache
[log] MCP server 'mem0' (lazy): registered 4 tool(s) from schema cache
[log] MCP: registered 21 lazy tool(s) from schema cache (no processes spawned)
[log]   MCP: 21 tool(s) from 2 server(s) (2 lazy, not spawned yet)
registered tools: 21   child processes spawned: 0
linear: status=lazy tools=17
mem0: status=lazy tools=4

Before the patch the same run printed MCP: 0 tool(s) from 0 server(s) (2 failed), linear: status=configured tools=0, mem0: status=configured tools=0. The install's errors.log had accumulated 2,787 zero-connected warnings and 20 retrying discovery thread lines for those two healthy servers.

Each of the four tests fails against the pre-fix source and passes with the fix; the TUI test also fails with only the lazy branch removed (it renders playwright [stdio]: failed). Merged onto current main locally: no conflicts, 769 tests across the MCP, startup and banner files pass under scripts/run_tests.sh, and tsc is clean. ruff clean.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs and issues — fix(mcp): name the per-server causes in the zero-connected warning #80948 touches the same warning's wording only
  • My PR contains only changes related to this fix
  • Ran the affected suites with scripts/run_tests.sh (every tests/tools/test_mcp*.py, test_mcp_startup, test_banner) plus vitest, tsc and eslint on the TUI files; relying on CI for the full matrix
  • I've added tests for my changes
  • I've tested on my platform: macOS 27.0 (Darwin 27.0.0), Python 3.11

Documentation & Housekeeping

  • Documentation — lazy key added to the MCP config reference (website/docs/user-guide/features/mcp.md) and cli-config.yaml.example
  • cli-config.yaml.example — updated (documentation only; no new config keys)
  • CONTRIBUTING.md / AGENTS.md — N/A
  • Cross-platform — pure Python, no platform APIs; footgun linter clean
  • Tool descriptions/schemas — N/A

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets tool/mcp MCP client and OAuth P2 Medium — degraded but workaround exists labels Sep 1, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference; please use your judgment.

Adds a distinct lazy status for schema-cache-registered MCP servers and fixes discovery summary/banner so an all-lazy startup isn't reported as "0 connected / N failed" (#56832). The status precedence (connected → connecting → failed → lazy → configured) is correct, and a failed first-use connect surfaces the error instead of the stale lazy state.

  • _discovery_registered_servers treats status == "lazy" as registered (mcp_startup.py:36). Since get_mcp_status reports failed for a lazy server with a recorded connect error, the lazy check won't mask failures — but confirm failed entries always carry a non-null error key so consumers don't misread them.
  • prior_lazy is snapshotted under _lock, but register_mcp_servers runs without it (mcp_tool.py:7908). If another thread lazily registers a server mid-discovery it may be double-counted in the summary; benign, but the comment could note the intended tolerance.
  • get_mcp_status copies _lazy_server_tool_names entries under the lock (mcp_tool.py:8000) — good, no mutable refs escape to callers.
  • The banner renders lazy servers in the non-failed branch with the cached tool count (banner.py:1181); verify srv['tools'] is always present for lazy entries from all status producers.
  • Tests cover the status matrix (lazy/configured/connecting/failed), no-retry for all-lazy, the zero-connected warning suppression, and the repeated-discovery summary; thorough.

@jonpol01
jonpol01 force-pushed the fix/mcp-status-lazy-servers branch 3 times, most recently from a30e594 to 7c82b6e Compare September 3, 2026 08:00
@jonpol01
jonpol01 force-pushed the fix/mcp-status-lazy-servers branch from 7c82b6e to 42ea452 Compare September 6, 2026 15:41

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 42ea452589d93b0afb8adb2938c5f5f2b8c5dd73 against current main 14ca27fa0601144b6ea4af1408a3b37c22456072 (merge base 00140a85574d4fc9e42f977a167aeda899b50ca9). The PR is one commit ahead and two commits behind; the two base-only commits touch only the Email gateway/tests, so I do not see an MCP merge collision. Exact-head CI, Docker, and Nix are all green; because this PR has one surviving commit, the commit train is every-commit green. Note, however, that JS & TS checks were skipped on this head because the diff contains no TS files, which matters for blocker 1 below.

I traced the current extracted MCP owners (mcp_tool_discovery, registration/cache state, startup coordinator), the banner and TUI session-info producer, the current TypeScript wire type, the lazy/cache tests, the recent -t/--toolsets discovery filtering, and the related/superseded work. The core state model here is good: connected -> disabled -> connecting -> failed -> lazy -> configured is the right precedence, gating lazy on the surviving lazy-config owner avoids stale tool-name residue, the banner correctly preserves the cached tool count, and the discovery summary no longer re-announces an already-lazy server.

There are two landing blockers.

1. lazy is now a Python→TUI wire value, but the consumer contract still says it cannot exist. tui_gateway/server.py forwards get_mcp_status() directly as info["mcp_servers"], while exact head still declares McpServerStatus.status in ui-tui/src/types.ts as only 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed'. This PR therefore changes the runtime protocol without changing its typed consumer. The green Python matrix does not cover that boundary—the JS/TS job was skipped. Please add 'lazy' to the TUI type and pin the wire/render contract with a small regression (a lazy server should remain connected: false, carry its cached tool count, and be representable by SessionInfo). This is especially important because the PR explicitly claims the dashboard/TUI mcp_servers surface as part of the fix.

2. The new startup-success predicate still mistakes an intentionally filtered discovery for a failed discovery. Current main gained the -t/--toolsets MCP spawn filter from #19000 after this PR was originally opened. On this exact head, set_mcp_server_filter("terminal") causes _discover_mcp_tools_without_interactive_oauth() to call discover_mcp_tools(allowed_mcp_names=["terminal"]); when configured MCP server names do not match, discovery correctly returns before even importing the SDK. But the post-run/re-entry checks then call unfiltered get_mcp_status(), see those untouched servers as configured, _discovery_registered_servers() returns false, the zero-connected warning fires, and a later start_background_mcp_discovery() retries the thread. That is the same defect class this PR is fixing—intentional non-connection being interpreted as discovery failure—just on the other side of the newly landed filter boundary. Please make the success/retry decision aware of the requested discovery set (or otherwise represent intentional skip distinctly), and add a regression for configured MCP + -t terminal: zero MCP spawn, no zero-connected warning, and no later retry. An all-disabled config is the same state-shape; either cover it too or deliberately separate “nothing usable was requested” from “requested discovery failed.”

Interlocks / provenance

  • The lazy-start design comes from #56832; main subsequently integrated the cache/startup work in 135a2945... and 1d5ecad5... with the original design credit preserved. This PR is a complementary correctness repair, not a duplicate or supersession.
  • The retry-after-zero path comes from #66981 and was salvaged into main. This PR is correctly tightening that retry contract rather than replacing that work.
  • The -t filter came from #19000 and was re-derived onto current main in e73257c..., 87597d3..., and af019a3.... That newer adjacent implementation is what exposes blocker 2; credit and ownership stay distinct.
  • #80948 is complementary diagnostics work on the same zero-connected branch, but it still patches a connected-only guard. Merge order needs an explicit semantic rebase: if #100648 lands first, #80948 must retain _discovery_registered_servers(...) while adding its formatter; if #80948 lands first, this PR must preserve that formatter while replacing the predicate. “Either order” is only safe if the resulting predicate is checked, not merely if Git reports a clean textual merge.
  • #99923 is broader profile-scoped discovery ownership. It already introduces an availability-style predicate specifically because a cache-backed lazy registration is usable without a live session, so it is adjacent/complementary rather than duplicate. #99925 is downstream test coverage and currently states/asserts that a lazy cache hit reports configured; after this PR that expectation must become lazy when that stack is rebased.
  • The recent MCP decomposition is respected here: the implementation now lives in the extracted tools/mcp_tool_discovery.py owner rather than growing the old mcp_tool.py facade. That part is aligned with the current architecture.

The underlying repair is strong work. Close these two boundary seams and the state model is substantially more honest end-to-end. 🚀

# merely waiting to be spawned.
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_tool_names

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new lazy value crosses the TUI session-info wire unchanged (tui_gateway/server.py assigns get_mcp_status() directly to info["mcp_servers"]), but exact head still types McpServerStatus.status in ui-tui/src/types.ts as only configured | connecting | connected | disabled | failed. Please update that consumer contract and pin a lazy payload. The exact-head JS/TS job is skipped here, so green CI cannot catch this drift.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3eca919852.

ui-tui/src/types.ts now types McpServerStatus.status as 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed' | 'lazy', and the TUI renders a lazy server with its cached tool count instead of falling through to the red failed branch. A lazy payload is pinned by ui-tui/src/__tests__/mcpLazyStatus.test.ts, so the contract is asserted on the JS side rather than relying on a job that is skipped here.

Thank you for flagging that green CI could not catch this drift — it was the reason the consumer went unnoticed.

Comment thread hermes_cli/mcp_startup.py
for entry in status or []:
if not isinstance(entry, dict):
continue
if entry.get("connected") or entry.get("status") == "lazy":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the all-lazy case, but current main's -t/--toolsets spawn filter creates another intentional non-connection state. With configured MCP servers and -t terminal, discovery intentionally filters every MCP out and returns before SDK import; get_mcp_status() still reports the full config as configured, so this predicate is false, the run logs zero-connected, and the next startup call retries. Please scope this decision to the requested discovery set (or encode intentional skip) and add the no-spawn/no-warning/no-retry regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 42c418ad52. This was a real second case and I had not covered it.

With -t terminal and MCP servers configured, discovery filters everything out and returns before the SDK import, while get_mcp_status() still reports the full config as configured — so the predicate was false, the run logged "zero connected servers", and the next call re-entered discovery. Every call, on every such oneshot.

The decision is now scoped to the requested set: when a filter is active and none of the configured server names appear in it, the run did what was asked and is not a failure. A filter that names a configured server which still did not come up is deliberately unchanged — that keeps its warning and its retry.

Three regressions, all red without the scoping:

  • a filtered-out run is not a failure, while a no-filter run with the same status still is;
  • a filter naming a configured server that did not come up still reports failure;
  • end to end through _any_mcp_connected(), a filtered-out run neither warns nor re-enters.

test_mcp_startup.py, test_mcp_lazy_start.py and test_banner.py together: 54 passed.

@jonpol01
jonpol01 force-pushed the fix/mcp-status-lazy-servers branch from 42ea452 to baca936 Compare September 13, 2026 07:47
@jonpol01

Copy link
Copy Markdown
Contributor Author

Thanks — this was a careful read, and blocker 1 was worse than you described. Rebased onto main at 205645ee42 (the branch was 1903 commits behind 00140a85, so the old head was not a meaningful thing to review any more) and pushed as two commits — new head baca9367e2, which still merges clean onto main as of b6b53c69a6.

Blocker 1 — accepted, and the render side was the real damage

You framed this as type drift. It is, but the consumer was already mis-rendering: the status chain in ui-tui/src/components/branding.tsx ends in an unconditional

) : (
  <Text color={t.color.error}>failed</Text>
)

so any status the chain doesn't name falls into it. With lazy on the wire and not in the chain, a healthy lazy server — tools registered from the schema cache, process not spawned yet — renders in the TUI as red failed. That is exactly the misreport this PR fixes in the classic CLI banner, reproduced on the second surface. Reverting the new branch and re-running the regression prints it plainly:

AssertionError: expected '  playwright [stdio]: fa…' to contain '3 tools'

Fixed in fix(tui): render a lazy MCP server, don't fall through to "failed":

  • 'lazy' added to the McpServerStatus.status union.
  • The row gets its own branch showing the cached tool count.
  • Contract pinned in ui-tui/src/__tests__/mcpLazyStatus.test.ts: the row renders the count rather than failed, a lazy server stays connected: false inside SessionInfo, and it is not counted in the connected N MCP headline — parity with banner.py's sum(s["connected"]), which the existing brandingMcpCount invariant already pins.

Two notes on how that is pinned, so the guarantee isn't overstated:

  • tsconfig.json excludes src/__tests__, so a test file can never be the compile-time guard. The guard is the s.status === 'lazy' comparison in branding.tsx: narrowing the union back to the old five values fails tsc with TS2367. Verified both directions.
  • The MCP accordion is collapsed by default, so SessionPanel never renders these rows and the row was untestable in place. It moves to an exported McpServerLine; no behavior change.

typecheck, eslint and vitest are clean in ui-tui.

Blocker 2 — mechanism confirmed, but it is main's, not this PR's

I traced it exactly as you described and it holds: mcp_startup.py passes the filter through, discover_mcp_tools empties the dict and returns before _ensure_mcp_sdk(), unfiltered get_mcp_status() still reports those servers configured, the predicate goes false, the zero-connected warning fires and the next call re-arms the thread.

What I can't agree with is placing it on this PR. I ran the same four hops against a clean origin/main worktree and against this PR's head. Byte-identical:

                     clean main                    this PR
  predicate          _any_mcp_connected            _discovery_registered_servers
  hop 1  discover(allowed=['terminal']) -> []; SDK imported: 0 time(s)        [same]
  hop 2  get_mcp_status() -> [('playwright','configured'), ('linear','configured')]  [same]
  hop 3  startup predicate -> False                                          [same]
  hop 4  discovery re-run 1x; warnings=['…previously exited with no connected
         servers; retrying discovery thread', '…completed with zero connected
         servers']                                                           [same]

_discovery_registered_servers widens the predicate from connected to connected or lazy. Against a -t filter that matched nothing there is no lazy server either, so the new predicate returns exactly what _any_mcp_connected returns. This PR neither introduces the defect nor makes it harder to fix.

I agree it is the same defect class and that it should be fixed. I'd rather not fix it here: the decision needs to be aware of the requested discovery set, which means either threading the filter into the success check or giving "nothing usable was requested" its own representation — and that is a second policy on a seam #99923 is already reworking (it introduces an availability-style predicate for precisely this reason). Landing a third shape in between is how these end up contradicting each other. Happy to open it as its own PR against main with the -t terminal + all-disabled regressions you asked for, and to sequence it after #99923 if you prefer that ownership. Say the word if you'd rather it ride along here and I'll add it.

What the rebase itself turned up

Worth flagging, since it's the "clean textual merge isn't a checked merge" hazard from your own note, and it bit this PR:

main moved the lazy registries to be keyed by resolved server key rather than by name (_lazy_server_configs[key] = … in mcp_tool_registration.py). Git merged the summary block in discover_mcp_tools without a conflict and left n in _core._lazy_server_configs looking at a name — silently always false. Outside a multiplexer key == name, so the whole existing lazy suite still passed with the broken lookup. Re-keyed through the keys map the function already builds.

The related trap: get_mcp_status gates its runtime dicts with _server_visible_in_scope, which reads the adoption/teardown maps. A lazy registration never populates those, so that predicate answers False even for the scope that owns the server — it would have hidden a profile's own lazy server under a multiplexer and reported it configured. The registration-level predicate is _key_visible_in_scope, which is what _resolve_server_key already uses for this same state.

Both are pinned by test_lazy_state_is_read_by_server_key_and_scoped_to_its_owner: the owning scope sees lazy with its cached count, another profile sees configured, and include_runtime=False hides it. Each of the three failure modes was checked by reverting the fix and confirming the test fails.

Interlocks

Tests on the rebased head: tests/hermes_cli/test_mcp_startup.py 20 passed, tests/tools/test_mcp_lazy_start.py 25 passed, tests/hermes_cli/test_banner.py 6 passed, plus the multiplex/key suites (test_mcp_multiplex_connection_keys, test_multiplex_mcp_discovery, test_multiplex_residue_parity) green. One unrelated failure in tests/test_tui_gateway_server.py::test_model_options_preserves_canonical_custom_row_after_agent_init reproduces identically on clean main with this PR's files reverted, so it is not from here.

… failed

Lazy startup (NousResearch#56832) registers a server's tools from the schema cache and
spawns the process on first use. Three consumers still decided whether a
server was alive from `connected` / `name in _servers` alone, so a healthy
all-lazy startup — the memory-saving configuration — was reported as a
total failure:

- get_mcp_status() had no branch for a lazily registered server (no
  session, not connecting, no error), so it fell through to
  `status: "configured", tools: 0`, the same shape as a server that never
  started, while its cached tools were registered and callable. The
  banner, `hermes mcp status` and the dashboard all showed that.
- hermes_cli/mcp_startup.py judged a discovery run by
  `any(entry.get("connected"))` at both sites, so every startup logged
  "Background MCP discovery completed with zero connected servers" and
  every later start_background_mcp_discovery() call treated the finished
  run as one that connected nothing (NousResearch#66981's retry) and re-spawned the
  discovery thread. One install had 2,787 of those warnings and 20
  retries in errors.log for two healthy lazy servers.
- discover_mcp_tools()'s summary counted `name in _servers` as connected
  and everything else as failed, logging "MCP: 0 tool(s) from 0 server(s)
  (2 failed)" right after registering every cached tool.

get_mcp_status() now reports such a server as `status: "lazy"` with its
cached tool count; connected, disabled, connecting (first-use spawn in
flight) and failed (first-use spawn failed) keep precedence over it. The
startup predicate counts a lazy registration as a run that left servers
usable, at both sites; a run that registered nothing still warns and
still retries. The discovery summary counts newly lazy servers as servers
with tools and no longer re-announces or fails a server that was already
lazy before the call. The banner renders `lazy` with its tool count
instead of falling into the red "failed" branch that catches unknown
statuses, and cli-config.yaml.example documents the `lazy` key, which was
missing from it.

Verified against a live install with two lazy stdio servers and a warm
cache: 21 tools registered, no child process spawned, both servers
reported as lazy, and the summary reads "MCP: 21 tool(s) from 2
server(s) (2 lazy, not spawned yet)".
`get_mcp_status()` gained a `lazy` status and `tui_gateway/server.py` forwards
that list verbatim as `info["mcp_servers"]`, but `McpServerStatus.status` in
ui-tui still typed only the five older values, so the runtime protocol and its
typed consumer had drifted. The JS/TS CI job is skipped on a Python-only diff,
so green CI could not catch it.

The render side was the user-visible half: the status chain in branding.tsx
ends in an unconditional red "failed", so a healthy lazy server — tools
registered from the schema cache, process not spawned yet — rendered as
*failed* in the TUI. That is the same misreport this PR fixes in the classic
CLI banner, on the second surface.

Adds 'lazy' to the union, gives the row its own branch showing the cached tool
count, and pins the contract: the row renders that count rather than "failed",
a lazy server stays `connected: false` inside `SessionInfo`, and it is not
counted in the connected "N MCP" headline (parity with banner.py's
sum(s["connected"])). The row moves into an exported `McpServerLine` because
the MCP accordion is collapsed by default, so `SessionPanel` never renders it.
…led discovery run

`-t/--toolsets` deliberately narrows which MCP servers are spawned, and when it excludes all of
them discovery returns before importing the SDK and starts nothing. `get_mcp_status()` still
reports the full config as `configured`, so the registered-servers predicate read that intentional
skip as a run that achieved nothing: `hermes -t terminal` on a machine with MCP servers logged
"Background MCP discovery completed with zero connected servers" and re-entered discovery on the
next call, every call.

Scope the decision to the requested set: when a filter is active and none of the configured server
names are in it, the run did exactly what was asked. A filter that names a configured server which
still did not come up is unchanged — that is a real failure and keeps its warning and its retry.

Thanks to @andrexibiza for finding this; it is the second intentional non-connection state on this
path, after the all-lazy case the PR started from.

Tests: the filtered-out run is not a failure while a no-filter run with the same status still is;
a filter naming a configured server that did not come up still reports failure; and end to end
through `_any_mcp_connected`, a filtered-out run neither warns nor re-enters. All three fail with
the scoping reverted.
…ed row by its wire entry; docs

Trim twenty tests to four, each red on the pre-fix code:

- lazy-start: one test walks a lazy registration through every surface (discovery summary on a
  first and a repeat run, get_mcp_status, the banner line), with the connecting / failed /
  stale-config controls folded in; the owner-scope test stays as the second.
- startup: one table-driven test. An all-lazy run and a -t filter that excluded every server are
  successful runs (no zero-connected warning, no retry); nothing-up and a named server that did
  not come up still are not.
- TUI: one test renders a lazy row with its cached count, never "failed", and keeps it out of
  the connected-MCP headline.

Dropped: the per-branch unit pins of _discovery_registered_servers (now one table), a
SessionInfo shape test that passes on the pre-fix code, and the whole-banner render (folded into
the banner-line assertion).

Fix: McpServerLine took the TUI's local McpServerStatus, which failed tsc at the SessionPanel
call site; it now takes the entry SessionInfo actually carries (the generated wire model).

Docs: the lazy key in the MCP config reference, including how the server shows until first use.
Comments cut to the why.
teknium1 pushed a commit that referenced this pull request Sep 16, 2026
… 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
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @jonpol01 — this landed on main via #112324 (fff1048), which cherry-picked/salvaged this PR's work with your authorship preserved, or superseded it with the same fix plus the sibling paths the review turned up.

Closing in favour of the landed change. If anything from this PR is still missing on main, say so here and we'll pick it up.

@teknium1 teknium1 closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

5 participants