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.
This commit is contained in:
teknium1
2026-09-15 12:31:55 -07:00
committed by Teknium
parent a17d0409be
commit abdb402701
10 changed files with 94 additions and 4 deletions

View File

@@ -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

View File

@@ -14154,6 +14154,7 @@
"disabled",
"connecting",
"failed",
"lazy",
"configured"
],
"title": "McpRuntimeStatus",

View File

@@ -1470,6 +1470,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:

View File

@@ -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

View File

@@ -334,3 +334,48 @@ class TestResolveServerLazy:
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

View File

@@ -381,6 +381,7 @@ class McpRuntimeStatus(WireEnum):
disabled = "disabled"
connecting = "connecting"
failed = "failed"
lazy = "lazy"
configured = "configured"

View File

@@ -324,6 +324,11 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<Text color={t.color.muted}>disabled</Text>
) : s.status === 'connecting' ? (
<Text color={t.color.warn}>connecting</Text>
) : s.status === 'lazy' ? (
// Registered from the schema cache, process not spawned yet: its tools are callable.
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'} <Text color={t.color.muted}>(lazy)</Text>
</Text>
) : s.status === 'configured' ? (
<Text color={t.color.muted}>configured</Text>
) : (

View File

@@ -176,7 +176,7 @@ export type SectionVisibility = Partial<Record<SectionName, DetailsMode>>
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

View File

@@ -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 |

View File

@@ -394,6 +394,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 |
@@ -640,6 +641,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.