Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/shared/src/gateway-contract.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/shared/src/gateway-contract.openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14154,6 +14154,7 @@
"disabled",
"connecting",
"failed",
"lazy",
"configured"
],
"title": "McpRuntimeStatus",
Expand Down
6 changes: 6 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
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)
Expand Down
13 changes: 12 additions & 1 deletion hermes_cli/mcp_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 28 additions & 2 deletions tests/hermes_cli/test_mcp_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}],
),
)

Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions tests/tools/test_mcp_lazy_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 32 additions & 6 deletions tools/mcp_tool_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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 ``<prefix> 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)


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 []
Expand All @@ -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:
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions tui_gateway/contracts/tools_mcp_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ class McpRuntimeStatus(WireEnum):
disabled = "disabled"
connecting = "connecting"
failed = "failed"
lazy = "lazy"
configured = "configured"


Expand Down
5 changes: 5 additions & 0 deletions ui-tui/src/components/branding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
) : (
Expand Down
2 changes: 1 addition & 1 deletion ui-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions website/docs/reference/mcp-config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions website/docs/user-guide/features/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
Loading