Skip to content

feat: auto-refresh MCP tools for slow servers via async late-binding - #42802

Closed
397239396 wants to merge 8 commits into
NousResearch:mainfrom
397239396:feat/configurable-mcp-discovery-timeout
Closed

feat: auto-refresh MCP tools for slow servers via async late-binding#42802
397239396 wants to merge 8 commits into
NousResearch:mainfrom
397239396:feat/configurable-mcp-discovery-timeout

Conversation

@397239396

@397239396 397239396 commented Jun 9, 2026

Copy link
Copy Markdown

Problem

When the TUI or CLI starts, it waits only 0.75 seconds for all MCP servers to connect. Slow servers like lark (~8s), redis (~10s), and ssh (~4s) miss this window, so their tools never get registered with the agent.

Users had to manually /reload-mcp after startup — a poor experience.

main branch has a partial fix: _schedule_mcp_late_refresh() in tui_gateway/server.py. But it has several problems:

Problems with main's _schedule_mcp_late_refresh

  1. TUI-only — lives inside server.py, CLI has no equivalent. CLI users with slow MCP servers are still stuck.

  2. No thread safety — directly mutates agent.tools and agent.valid_tool_names without any lock. The main thread reads these during tool iteration; a concurrent write mid-iteration can produce inconsistent state.

  3. No thread deduplication — spawns a new daemon thread per session. If multiple sessions are created rapidly, multiple background threads race to update the same agent.

  4. Fragile tool-change detection — compares tool counts (len(new_defs) == len(agent.tools)), not tool names. If a slow server connects with 5 tools and the agent already has 5 from other servers, the refresh is silently skipped.

  5. TUI thread not synced to mcp_startupentry.py creates its own _mcp_discovery_thread but never publishes it to hermes_cli.mcp_startup._mcp_discovery_thread. Any shared module trying to check "is MCP discovery still running?" gets None, even though TUI discovery is active. This is a latent bug that blocks any future shared MCP infrastructure.

Solution

Shared two-phase async MCP discovery in hermes_cli/mcp_startup.py:

Phase When Behavior Blocking
1 (startup) At agent build time Wait 0.75s for fast servers, build agent with available tools 0.75s
2 (background) After agent created Daemon thread waits for discovery to complete, then auto-merges new tools into the running agent None

Changes

File Change
hermes_cli/mcp_startup.py Added spawn_late_mcp_refresh() — shared background thread that waits for _mcp_discovery_event, then merges tools from newly-connected servers into the agent. Thread-safe (_agent_tools_lock), deduplicates threads, detects changes by tool name not count.
hermes_cli/cli_agent_setup_mixin.py Call spawn_late_mcp_refresh(agent) after CLI agent creation — CLI now also gets late-bound tools.
tui_gateway/server.py Replace TUI-local _schedule_mcp_late_refresh with shared spawn_late_mcp_refresh + on_refreshed callback that emits session.info. Removes ~60 lines of duplicate TUI-only logic.
tui_gateway/entry.py Bug fix: Sync TUI's _mcp_discovery_thread to mcp_startup._mcp_discovery_thread so shared code can see TUI discovery state. Without this, spawn_late_mcp_refresh would skip the background path and only do an inline check.
tests/hermes_cli/test_mcp_startup.py 183 lines of new tests: inline refresh, background thread, dedup, timeout, callback failure.
tests/test_tui_mcp_late_refresh.py Deleted — replaced by the shared tests above.

Why this is better than main's approach

Aspect main (_schedule_mcp_late_refresh) This PR (spawn_late_mcp_refresh)
Scope TUI only CLI + TUI (shared)
Thread safety No lock on agent.tools _agent_tools_lock guards mutations
Thread dedup None (one thread per session) Single late-refresh thread, guarded by _mcp_discovery_lock
Change detection Compares tool count Compares tool name set
Discovery-already-done No-op (skips entirely) Inline refresh check right away
Callback system Hardcoded _emit Pluggable on_refreshed callback
Entry.py sync Discovery thread invisible to shared code Bug fix: publishes thread to mcp_startup

What this replaces

  • _schedule_mcp_late_refresh in server.py — replaced by shared spawn_late_mcp_refresh
  • HERMES_MCP_DISCOVERY_TIMEOUT environment variable — no longer needed

Benefits

  • Startup is instant (~0.75s wait, no more)
  • All tools are available — slow servers auto-merge once connected, no user action needed
  • No configuration required — works out of the box
  • Thread-safe — no race conditions on tool list mutation
  • Works for both CLI and TUI — single implementation, consistent behavior
  • Tested — 6 test scenarios covering inline, background, dedup, timeout, and error paths

397239396 added 2 commits June 9, 2026 19:19
Add HERMES_MCP_DISCOVERY_TIMEOUT environment variable to allow users
with slow MCP servers (e.g. lark ~8s, redis ~10s, ssh ~4s) to increase
the discovery timeout. Default remains 0.75s for backward compatibility.

Both tui_gateway/entry.py and hermes_cli/mcp_startup.py are updated.

Resolution order:
  1. Explicit timeout argument (callers can override)
  2. HERMES_MCP_DISCOVERY_TIMEOUT env var
  3. Default 0.75s
@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists tool/mcp MCP client and OAuth comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/cli CLI entry point, hermes_cli/, setup wizard labels Jun 9, 2026
397239396 added 2 commits June 9, 2026 19:45
…ound thread

Instead of blocking startup for 15s waiting for all MCP servers to connect,
use a two-phase approach:

Phase 1 (startup): Brief 0.75s wait for fast servers, then build agent
Phase 2 (background): Spawn a daemon thread that waits for MCP discovery
to complete, then auto-refreshes the agent's tools in-place

This means:
- Startup is instant (~0.75s wait, not 15s)
- Agent starts with whatever tools were ready at build time
- Slow servers (lark ~8s, redis ~10s, ssh ~4s) auto-merge their tools
  once they connect, without user intervention
- TUI emits updated session.info so the frontend reflects new tools
- No need for HERMES_MCP_DISCOVERY_TIMEOUT env var workaround
With the late-binding async refresh mechanism, the env var is no longer
needed. Phase 1 always waits 0.75s for fast servers; slow servers are
handled by spawn_late_mcp_refresh in the background.
@397239396 397239396 changed the title feat: make MCP discovery timeout configurable via env var feat: auto-refresh MCP tools for slow servers via async late-binding Jun 9, 2026
397239396 added 3 commits June 9, 2026 22:37
- entry.py: publish discovery thread to mcp_startup module so
  spawn_late_mcp_refresh can see it
- mcp_startup.py: when discovery thread is already finished,
  do inline refresh check instead of skipping

Without these fixes, slow MCP servers (lark/redis/ssh) would
never have their tools loaded into the agent.
tui_gateway/server.py had CRLF line endings causing merge conflicts
with main. Convert back to LF.
P0 fixes:
- Remove dead 'import os' from mcp_startup.py
- Add _agent_tools_lock for thread-safe agent.tools updates
- Normalize all line endings to LF (was CRLF in 3 files)
- Add 6 unit tests for spawn_late_mcp_refresh

P1 fixes:
- Add on_refreshed callback for CLI path (user feedback)
- Guard _mcp_discovery_started = True with is_alive() check

P2 fixes:
- Reduce discovery wait timeout from 120s to 30s (_LATE_REFRESH_DISCOVERY_TIMEOUT_S)
- Move _mcp_late_refresh_thread assignment inside lock
- Extract _update_agent_tools helper for consistent tool swap
@397239396

Copy link
Copy Markdown
Author

Superseded by #48431 — resolved merge conflicts with latest main and squashed into a clean single commit.

…efresh

- Replaced main's local _schedule_mcp_late_refresh with shared spawn_late_mcp_refresh
- Removed duplicate function, using unified mcp_startup.spawn_late_mcp_refresh
- All TUI and CLI paths now use the same late-binding mechanism
@397239396 397239396 reopened this Jun 18, 2026
alt-glitch added a commit that referenced this pull request Jun 19, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (#48403/#41630/#42802) don't close it.
teknium1 pushed a commit that referenced this pull request Jun 19, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (#48403/#41630/#42802) don't close it.
gnalvesteffer pushed a commit to gnalvesteffer/hermes-agent that referenced this pull request Jun 19, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the MCP late-binding work. An automated hermes-sweeper review found that the requested behavior is already implemented on current main by a stronger, cache-safe path.

  • agent/turn_context.py:176-200 refreshes late-registered MCP tools before each turn's first API call, covering the CLI/TUI/gateway agent path without changing an in-flight cached prefix.
  • tools/mcp_tool.py:5231-5350 provides the shared name-diffed, agent-scoped, additive-preserving, generation-safe tool snapshot refresh.
  • tui_gateway/server.py:4370-4441 retains late TUI refresh only while the session is pre-first-turn; tui_gateway/entry.py:235-290 covers both stdio and desktop/dashboard discovery-thread owners.
  • This landed through 93d6e730288e4ffab8076a0539f25e37a71f238f and follow-up hardening b6e2a54a94f58f9ebafa79f45d45b0ccb2b17043 (released in v2026.6.19).

The PR's own spawn_late_mcp_refresh() lacks the current pre-turn cache gate, so it would regress the repository rule against changing toolsets mid-conversation. The contributor's note that this was superseded by #48431 is also consistent with this review.

@teknium1 teknium1 closed this Jul 14, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 14, 2026
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…binding)

A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting
after the agent's one-time tool snapshot was uncallable for the rest of the
session. The merged pre-first-turn late-refresh only helps during the dead air
before the user's first keystroke; once a turn starts it bails to protect the
prompt cache, so a user who types before the server connects never gets the
tools without a manual /reload-mcp.

Refresh the snapshot in the per-turn prologue (build_turn_context), before this
turn's first API call assembles tools=. This is cache-safe by construction: the
refresh only ever extends a fresh request prefix at a turn boundary, never
mutates the cached prefix of an in-flight turn. So late tools become callable on
the user's NEXT turn automatically, with no /reload-mcp and no cache cost.

- tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with
  no MCP servers (the common case) skip the rebuild entirely.
- agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the
  top of the prologue when MCP servers are registered.
- tests: 3 contract tests through the real build_turn_context (adds late tool;
  skipped when no servers; no snapshot churn when unchanged).

.hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety
constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
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/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:implemented-on-main Sweeper: behavior already present on current main tool/mcp MCP client and OAuth type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants