fix(mcp): connect servers added to config on the next agent build

A long-lived backend (Desktop's tui_gateway) runs MCP discovery once. Every
agent build re-enters start_background_mcp_discovery, but that returned as
soon as any server was connected, so a server added with `hermes mcp add`
after startup never reached a new session; only /reload-mcp or a restart
picked it up.

Re-entry now also runs discovery when an enabled configured server is not
live and its connect cooldown has lapsed. Discovery is additive, so live
servers and open sessions are untouched; the new session gets the tools.
The pending-server computation is factored out of
reconcile_mcp_servers_with_config so both callers share it.

Refs #76954
This commit is contained in:
Hermes Agent
2026-09-24 23:51:37 -05:00
committed by brooklyn!
parent 30e025c0c4
commit a052836559
3 changed files with 80 additions and 15 deletions

View File

@@ -83,11 +83,21 @@ def _any_mcp_connected() -> bool:
return _discovery_registered_servers(get_mcp_status() or []) return _discovery_registered_servers(get_mcp_status() or [])
def _servers_awaiting_connect() -> list[str]:
from tools.mcp_tool_discovery import mcp_servers_awaiting_connect
pending = mcp_servers_awaiting_connect()
return pending if _mcp_server_filter is None else [n for n in pending if n in _mcp_server_filter]
def start_background_mcp_discovery(*, logger, thread_name: str) -> None: def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
"""Spawn one background MCP discovery thread per profile home. """Spawn one background MCP discovery thread per profile home.
If the first run exits without connecting any server (e.g. startup cancellation / OOM restart), If the first run exits without connecting any server (e.g. startup cancellation / OOM restart),
later calls may retry instead of pinning the profile in "already started" with zero MCP tools. later calls may retry instead of pinning the profile in "already started" with zero MCP tools.
Likewise a server added to ``mcp_servers`` after that run (``hermes mcp add`` against a running
Desktop backend) is connected by the next call, which every agent build makes, so a new session
gets its tools without a reload (#76954). Discovery is additive: live servers are untouched.
""" """
home_key = hermes_home_key() home_key = hermes_home_key()
with _mcp_discovery_lock: with _mcp_discovery_lock:
@@ -96,14 +106,19 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
if thread is not None and thread.is_alive(): if thread is not None and thread.is_alive():
return return
try: try:
if _any_mcp_connected(): connected = _any_mcp_connected()
return pending = _servers_awaiting_connect() if connected else []
except Exception: except Exception:
return return
logger.warning( if connected and not pending:
"Background MCP discovery previously exited with no connected " return
"servers; retrying discovery thread" if connected:
) logger.info("MCP server(s) %s not connected yet; running discovery", ", ".join(pending))
else:
logger.warning(
"Background MCP discovery previously exited with no connected "
"servers; retrying discovery thread"
)
_mcp_discovery_started.discard(home_key) _mcp_discovery_started.discard(home_key)
_mcp_discovery_thread.pop(home_key, None) _mcp_discovery_thread.pop(home_key, None)

View File

@@ -465,3 +465,38 @@ def test_lazy_only_discovery_counts_as_usable_at_both_startup_sites(monkeypatch,
assert calls["mcp"] == (2 if retried else 1) assert calls["mcp"] == (2 if retried else 1)
assert any("zero connected" in w for w in warnings) is retried 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 assert any("retrying discovery thread" in w for w in warnings) is retried
def test_server_added_after_discovery_is_connected_by_the_next_agent_build(monkeypatch, tmp_path):
"""#76954: ``hermes mcp add`` against a running Desktop backend. Discovery already ran and left
``github`` live, so the re-entry every agent build makes returned early and the new server's
tools never reached a new session. It must run discovery again, and only while something
configured is still unconnected."""
from tools import mcp_tool
from tools import mcp_tool_config as _config
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("mcp_servers:\n github:\n url: https://mcp.example.test/gh\n")
configured = {"github": {"url": "https://mcp.example.test/gh"}}
monkeypatch.setattr(_config, "_load_mcp_config", lambda: dict(configured))
monkeypatch.setitem(mcp_tool._servers, "github", object())
monkeypatch.setitem(mcp_tool._server_scope_keys, "github", None)
monkeypatch.setattr(mcp_startup, "_any_mcp_connected", lambda: True)
runs: list = []
monkeypatch.setattr(mcp_startup, "_discover_mcp_tools_without_interactive_oauth", lambda: runs.append(1))
logger = types.SimpleNamespace(debug=lambda *_a, **_k: None, info=lambda *_a, **_k: None,
warning=lambda *_a, **_k: None)
def build_agent():
mcp_startup.start_background_mcp_discovery(logger=logger, thread_name="t")
thread = mcp_startup._current_home_thread()
if thread is not None:
thread.join(timeout=5.0)
build_agent() # backend start
build_agent() # new session, config unchanged: github is live, nothing to do
assert len(runs) == 1
configured["linear"] = {"url": "https://mcp.example.test/linear"} # hermes mcp add linear
build_agent()
assert len(runs) == 2

View File

@@ -10,7 +10,7 @@ import logging
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Set, Tuple
from tools.mcp_tool_common import _core, _parse_boolish, mcp_server_enabled from tools.mcp_tool_common import _core, _parse_boolish, mcp_server_enabled
from tools import mcp_tool_config as _config from tools import mcp_tool_config as _config
from tools import mcp_tool_errors as _errors from tools import mcp_tool_errors as _errors
@@ -629,6 +629,14 @@ def reconcile_mcp_servers_with_config() -> Dict[str, List[str]]:
_lifecycle.shutdown_mcp_servers(scope=scope, names=set(stale)) _lifecycle.shutdown_mcp_servers(scope=scope, names=set(stale))
for key in lazy: for key in lazy:
_forget_lazy_server(key) _forget_lazy_server(key)
added = _awaiting_connect(wanted, scope)
if added:
discover_mcp_tools()
return {"removed": stale + sorted(_key_name(k) for k in lazy), "added": added,
"pending": sorted(connecting - wanted)}
def _awaiting_connect(wanted: Set[str], scope) -> List[str]:
with _core._lock: with _core._lock:
# Same resolution ``_select_new_servers`` applies: this scope's own connection OR a shared # Same resolution ``_select_new_servers`` applies: this scope's own connection OR a shared
# one it adopted from another profile counts as live. Owner==scope alone misses the adopted # one it adopted from another profile counts as live. Owner==scope alone misses the adopted
@@ -637,15 +645,22 @@ def reconcile_mcp_servers_with_config() -> Dict[str, List[str]]:
known = {name for name in wanted known = {name for name in wanted
if (key := _resolve_server_key(name, scope, current=False)) in _core._servers if (key := _resolve_server_key(name, scope, current=False)) in _core._servers
or key in _core._server_connecting or key in _core._lazy_server_configs} or key in _core._server_connecting or key in _core._lazy_server_configs}
# A configured server that is not live is retried here — this is the only reviver for one whose # A configured server that is not live is retried — the only way one whose FIRST connect
# FIRST connect failed (#112445) — but only once its connect cooldown lapsed: ``discover_mcp_tools`` # failed comes back (#112445) — but only once its connect cooldown lapsed: ``discover_mcp_tools``
# would skip it anyway, and entering it takes the cross-process discovery lock (up to 120 s of # would skip it anyway, and entering it takes the cross-process discovery lock (up to 120 s of
# waiting when another process holds it) and logs a failed pass, every tick, for nothing. # waiting when another process holds it) and logs a failed pass, every call, for nothing.
added = sorted(name for name in wanted - known if not _connect_cooldown_active(name)) return sorted(name for name in wanted - known if not _connect_cooldown_active(name))
if added:
discover_mcp_tools()
return {"removed": stale + sorted(_key_name(k) for k in lazy), "added": added, def mcp_servers_awaiting_connect() -> List[str]:
"pending": sorted(connecting - wanted)} """Enabled ``mcp_servers`` entries the current registry scope has no live, connecting or lazy
registration for, whose connect cooldown has lapsed: added to config since the last discovery
run, or one whose earlier connect failed. Read-only — what the next :func:`discover_mcp_tools`
would connect."""
with _owner_secret_scope():
servers = _config._load_mcp_config()
wanted = {name for name, cfg in servers.items() if mcp_server_enabled(cfg)}
return _awaiting_connect(wanted, _core._mcp_registry_scope())
def _forget_lazy_server(key) -> None: def _forget_lazy_server(key) -> None: