Skip to content
Closed
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
29 changes: 29 additions & 0 deletions tests/test_tui_gateway_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,32 @@ async def send_text(self, line):
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
loop.close()


def test_ws_starts_mcp_discovery_before_ready(monkeypatch):
import tui_gateway.entry as entry

calls = []
events = []

monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0)
monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: calls.append("mcp"))

class FakeWS:
async def accept(self):
events.append("accept")

async def send_text(self, line):
if '"gateway.ready"' in line:
events.append(f"ready_after_{len(calls)}")

async def receive_text(self):
raise ws_mod._WebSocketDisconnect()

async def close(self):
pass

asyncio.run(ws_mod.handle_ws(FakeWS()))

assert calls == ["mcp"]
assert events == ["accept", "ready_after_1"]
96 changes: 49 additions & 47 deletions tui_gateway/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,58 +210,60 @@ def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
thread.join(timeout=timeout)


def main():
_install_sidecar_publisher()

# MCP tool discovery — runs in a background daemon thread so a slow or
# unreachable MCP server can't freeze TUI startup. Previously this ran
# inline before ``gateway.ready``, which meant any configured-but-down
# server stalled the whole shell on "summoning hermes…" for the full
# connect-retry backoff (e.g. a dead stdio/http server burns 1+2+4s of
# retries → ~7s of dead air before the composer appears). Discovery is
# idempotent and registers tools into the shared registry as servers
# connect. The agent isn't built until the first prompt, at which point
# ``_make_agent`` briefly joins this thread (``wait_for_mcp_discovery``,
# bounded) so already-spawning fast servers land in the tool snapshot —
# a dead server is simply not waited on past the bound. ``/reload-mcp``
# rebuilds the snapshot for servers that connect later in the session.
#
# Cold-start guard: importing ``tools.mcp_tool`` transitively pulls the
# full MCP SDK (mcp, pydantic, httpx, jsonschema, starlette parsers —
# ~200ms on macOS). The overwhelming majority of users have no
# ``mcp_servers`` configured, in which case every byte of that import is
# wasted. Check the config first (cheap) and only spawn the discovery
# thread when there's actually MCP work to do, so the import cost stays
# off the path entirely for the common case.
def _has_configured_mcp_servers() -> bool:
"""Return whether startup should attempt MCP discovery.

Keep this cheap so non-MCP users do not pay the MCP SDK import cost.
"""
try:
from hermes_cli.config import read_raw_config
_mcp_servers = (read_raw_config() or {}).get("mcp_servers")
_has_mcp_servers = isinstance(_mcp_servers, dict) and len(_mcp_servers) > 0

mcp_servers = (read_raw_config() or {}).get("mcp_servers")
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
except Exception:
# Be conservative: if we can't decide, fall back to attempting
# discovery (still backgrounded, so it can't block startup).
_has_mcp_servers = True
if _has_mcp_servers:
def _discover_mcp_background() -> None:
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.warning(
"Background MCP tool discovery failed", exc_info=True
)

import threading as _mcp_threading
_mcp_thread = _mcp_threading.Thread(
target=_discover_mcp_background,
name="tui-mcp-discovery",
daemon=True,
)
_mcp_thread.start()
# Publish the handle so the first agent build can briefly wait for
# already-spawning fast servers to land (see wait_for_mcp_discovery).
global _mcp_discovery_thread
_mcp_discovery_thread = _mcp_thread
# discovery. The caller starts it in the background.
return True


def ensure_mcp_discovery_started() -> None:
"""Start background MCP discovery once for gateway entrypoints.

``main()`` covers the stdio/TUI path. WebSocket/Desktop entrypoints can
accept sessions without running ``main()``, so they must call this helper
before the first agent snapshots its tool list.
"""
global _mcp_discovery_thread

if _mcp_discovery_thread is not None:
return

if not _has_configured_mcp_servers():
return

def _discover_mcp_background() -> None:
try:
from tools.mcp_tool import discover_mcp_tools

discover_mcp_tools()
except Exception:
logger.warning("Background MCP tool discovery failed", exc_info=True)

import threading as _mcp_threading

_mcp_discovery_thread = _mcp_threading.Thread(
target=_discover_mcp_background,
name="tui-mcp-discovery",
daemon=True,
)
_mcp_discovery_thread.start()


def main():
_install_sidecar_publisher()

ensure_mcp_discovery_started()

if not write_json({
"jsonrpc": "2.0",
Expand Down
11 changes: 11 additions & 0 deletions tui_gateway/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,17 @@ async def handle_ws(ws: Any) -> None:

transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer)

# WebSocket/Desktop sessions do not run tui_gateway.entry.main(), so
# start the same background MCP discovery path here before advertising
# gateway.ready. _make_agent() will briefly wait for this thread before
# snapshotting tools.
try:
from tui_gateway.entry import ensure_mcp_discovery_started

ensure_mcp_discovery_started()
except Exception:
_log.warning("ws MCP discovery startup failed", exc_info=True)

ready_ok = await transport.write_async(
{
"jsonrpc": "2.0",
Expand Down