{"repo":"NousResearch/hermes-agent","public_id":"37013","display_label":"MCP tool discovery racing agent tool snapshot in one-shot/TUI","summary":"Most members describe the same theme: background MCP discovery can finish after the agent builds its tool snapshot, especially for slow stdio/HTTP servers in one-shot (-z), kanban worker, headless, or TUI modes. The issues and PRs propose waiting longer, making the timeout configurable, or exposing late-connecting tools.","subsystem":"cron","counts":{"members":24,"open_issues":0,"open_issues_distinct":0,"open_prs":5,"closed_issues":8,"merged_prs":2,"attempt_prs":14},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":75933,"merged_at":"2026-08-01T06:57:32+00:00"},"resolution":{"state":"partially_resolved","by":"pr-49208,pr-75933"},"freshness":{"state":"verified","last_verified_at":"2026-08-01T06:57:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09014492753623186},"fix_edges":[{"issue_number":37013,"pr_number":75933,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-08-01T06:57:32+00:00"},{"issue_number":38448,"pr_number":75933,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-08-01T06:57:32+00:00"},{"issue_number":41625,"pr_number":49208,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-19T18:57:43+00:00"},{"issue_number":47121,"pr_number":49208,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-19T18:57:43+00:00"},{"issue_number":51316,"pr_number":75933,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-08-01T06:57:32+00:00"},{"issue_number":68137,"pr_number":75933,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-08-01T06:57:32+00:00"}],"members":[{"kind":"issue","number":37013,"title":"[Bug]: -z/headless oneshots silently drop slow-connecting MCP servers (0.75 s discovery wait too short for container/stdio-backed servers)","url":null,"state":"closed"},{"kind":"issue","number":38448,"title":"hermes -z can miss MCP tools because oneshot snapshots tools before MCP discovery","url":null,"state":"closed"},{"kind":"issue","number":41625,"title":"MCP tools discovered but not exposed to agent in TUI mode","url":null,"state":"closed"},{"kind":"issue","number":47121,"title":"MCP tools missing in TUI sessions due to wait_for_mcp_discovery timeout (0.75s) being shorter than MCP discovery time (~6s)","url":null,"state":"closed"},{"kind":"issue","number":51316,"title":"Bug: MCP tool snapshot misses stdio servers with slow startup in single-query mode (-z)","url":null,"state":"closed"},{"kind":"issue","number":54691,"title":"One-shot kanban workers miss large MCP servers' tools (snapshot races discovery)","url":null,"state":"closed"},{"kind":"issue","number":57170,"title":"MCP HTTP tools registered after agent_init are invisible (race condition)","url":null,"state":"closed"},{"kind":"issue","number":68137,"title":"One-shot mode (-z) builds the agent without waiting for background MCP discovery — slow stdio servers are silently dropped","url":null,"state":"closed"},{"kind":"pr","number":37104,"title":"fix(mcp): wait longer for headless discovery","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37301,"title":"fix(mcp): raise headless discovery timeout and add HERMES_MCP_DISCOVERY_WAIT override","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"094aa85c3","number":0},{"kind":"pr","value":"#37013","number":37013}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":37393,"title":"fix(mcp): respect HERMES_MCP_DISCOVERY_TIMEOUT env var for slow-connecting servers","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":38620,"title":"Fix/mcp discovery non gateway paths","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#38448","number":38448}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41630,"title":"fix(mcp): increase wait_for_mcp_discovery timeout from 0.75s to 3.0s","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":49160,"title":"fix(mcp): expose late-connecting MCP tools to the agent (TUI/CLI/gateway)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49208","number":49208}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49208,"title":"fix(mcp): expose late-connecting MCP tools to the agent (TUI/CLI/gateway)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/background_review.py","agent/turn_context.py","cli.py","gateway/run.py","hermes_cli/config.py","hermes_cli/mcp_startup.py","tests/agent/test_turn_context.py","tests/tools/test_refresh_agent_mcp_tools.py","tools/mcp_tool.py","tui_gateway/entry.py","tui_gateway/server.py"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -531,7 +531,14 @@ def init_agent(\n     agent._last_activity_desc: str = \"initializing\"\n     agent._current_tool: str | None = None\n     agent._api_call_count: int = 0\n-\n+    # Opt-out flag for the between-turns MCP tool refresh (build_turn_context).\n+    # Set on internal forks (e.g. background_review) that must keep ``tools[]``\n+    # byte-identical to a parent for provider cache parity.\n+    agent._skip_mcp_refresh = False\n+    # Registry generation the current tool snapshot was derived from. Lets a\n+    # late/concurrent refresh reject a stale (older-generation) rebuild instead\n+    # of clobbering a newer one. Set adjacent to the tool snapshot below.\n+    agent._tool_snapshot_generation = 0\n     # Rate limit tracking — updated from x-ratelimit-* response headers\n     # after each API call.  Accessed by /usage slash command.\n     agent._rate_limit_state: Optional[\"RateLimitState\"] = None","@@ -953,7 +960,14 @@ def init_agent(\n             print(f\"🔄 Fallback chain ({len(agent._fallback_chain)} providers): \" +\n                   \" → \".join(f\"{f['model']} ({f['provider']})\" for f in agent._fallback_chain))\n \n-    # Get available tools with filtering\n+    # Get available tools with filtering. Capture the registry generation this\n+    # snapshot is derived from FIRST, so a later concurrent refresh can tell\n+    # whether it holds a newer or staler view (see refresh_agent_mcp_tools).\n+    try:\n+        from tools.registry import registry as _snapshot_registry\n+        agent._tool_snapshot_generation = _snapshot_registry._generation\n+    except Exception:\n+        agent._tool_snapshot_generation = 0\n     agent.tools = _ra().get_tool_definitions(\n         enabled_toolsets=enabled_toolsets,\n         disabled_toolsets=disabled_toolsets,"]},{"file":"agent/background_review.py","hunks":["@@ -535,6 +535,13 @@ def _bg_review_auto_deny(command, description, **kwargs):\n             )\n             review_agent._memory_write_origin = \"background_review\"\n             review_agent._memory_write_context = \"background_review\"\n+            # The review fork pins the parent's cached system prompt and keeps\n+            # ``tools[]`` byte-identical to the parent so its outbound request\n+            # hits the same provider cache prefix (see the toolset-parity note\n+            # above). The between-turns MCP refresh in build_turn_context would\n+            # add late-connecting MCP tools to this fork and break that parity,\n+            # so opt the review fork out of it.\n+            review_agent._skip_mcp_refresh = True\n             review_agent._memory_store = agent._memory_store\n             review_agent._memory_enabled = agent._memory_enabled\n             review_agent._user_profile_enabled = agent._user_profile_enabled"]},{"file":"agent/turn_context.py","hunks":["@@ -112,6 +112,24 @@ def build_turn_context(\n     # Restore the primary runtime if the previous turn activated fallback.\n     agent._restore_primary_runtime()\n \n+    # Between-turns MCP refresh: an MCP server that finished connecting since\n+    # the previous turn (slow HTTP/OAuth servers routinely take 2-6s on a cold\n+    # connect, missing the bounded startup wait) lands in THIS turn's tool\n+    # snapshot.  This is cache-safe by construction: it runs in the per-turn\n+    # prologue, before this turn's first API call assembles ``tools=``, so it\n+    # only ever extends a fresh request prefix — it never mutates the cached\n+    # prefix of an in-flight turn.  No-op when no MCP servers are registered\n+    # (the common case, gated by the cheap ``has_registered_mcp_tools`` check)\n+    # or when the tool set is unchanged (``refresh_agent_mcp_tools`` diffs by\n+    # name and leaves the snapshot untouched on no-change).\n+    try:\n+        if not getattr(agent, \"_skip_mcp_refresh\", False):\n+            from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools\n+            if has_registered_mcp_tools():\n+                refresh_agent_mcp_tools(agent, quiet_mode=True)\n+    except Exception:\n+        logger.debug(\"between-turns MCP tool refresh skipped\", exc_info=True)\n+\n     # Sanitize surrogate characters from user input.\n     if isinstance(user_message, str):\n         user_message = sanitize_surrogates(user_message)"]},{"file":"cli.py","hunks":["@@ -9661,16 +9661,35 @@ def _reload_mcp(self):\n             else:\n                 print(f\"  🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)\")\n \n-            # Refresh the agent's tool list so the model can call new tools\n+            # Refresh the agent's tool list so the model can call new tools.\n+            # Route through the shared helper so this CLI /reload-mcp path stays\n+            # in lockstep with the TUI RPC / gateway reload / late-binding paths\n+            # (name-diff, thread-safe, and — critically — additive-preserving so\n+            # memory-provider and context-engine tools survive the rebuild).\n             if self.agent is not None:\n-                self.agent.tools = get_tool_definitions(\n-                    enabled_toolsets=self.agent.enabled_toolsets\n-                    if hasattr(self.agent, \"enabled_toolsets\") else None,\n+                from tools.mcp_tool import refresh_agent_mcp_tools\n+                # Explicit reload: pick up MCP servers the user ENABLED in config\n+                # this session. self.enabled_toolsets was resolved once at\n+                # startup; merge in any now-connected server names (unless the\n+                # user pinned `all`/`*`, which already includes everything) so a\n+                # freshly-added server isn't filtered out. Mirrors startup, where\n+                # MCP server names are part of enabled_toolsets (see __init__).\n+                enabled_override = None\n+                et = self.enabled_toolsets\n+                if et and \"all\" not in et and \"*\" not in et:\n+                    merged = list(et)\n+                    for _name in sorted(connected_servers):\n+                        if _name not in merged:\n+                            merged.append(_name)\n+                    enabled_override = merged\n+                refresh_agent_mcp_tools(\n+                    self.agent,\n+                    enabled_override=enabled_override,\n                     quiet_mode=True,\n                 )\n-                self.agent.valid_tool_names = {\n-                    tool[\"function\"][\"name\"] for tool in self.agent.tools\n-                } if self.agent.tools else set()\n+                # Keep the CLI's own list in sync with what the agent now uses.\n+                if enabled_override is not None:\n+                    self.enabled_toolsets = enabled_override\n \n             # Inject a message at the END of conversation history so the\n             # model knows tools changed.  Appended after all existing"]},{"file":"gateway/run.py","hunks":["@@ -11656,7 +11656,7 @@ async def _execute_mcp_reload(self, event: MessageEvent) -> str:\n             # consented to the prompt-cache invalidation via the slash-confirm\n             # gate in _handle_reload_mcp_command before we reach this point.\n             try:\n-                from model_tools import get_tool_definitions\n+                from tools.mcp_tool import refresh_agent_mcp_tools\n                 _cache = getattr(self, \"_agent_cache\", None)\n                 _cache_lock = getattr(self, \"_agent_cache_lock\", None)\n                 if _cache_lock is not None and _cache:","@@ -11668,15 +11668,16 @@ async def _execute_mcp_reload(self, event: MessageEvent) -> str:\n                                 continue\n                             if _agent is None:\n                                 continue\n-                            new_defs = get_tool_definitions(\n-                                enabled_toolsets=getattr(_agent, \"enabled_toolsets\", None),\n-                                disabled_toolsets=getattr(_agent, \"disabled_toolsets\", None),\n-                                quiet_mode=True,\n-                            )\n-                            _agent.tools = new_defs\n-                            _agent.valid_tool_names = {\n-                                t[\"function\"][\"name\"] for t in new_defs\n-                            } if new_defs else set()\n+                            # Preserve each cached agent's build-time toolset\n+                            # selection EXACTLY: a gateway session built with a\n+                            # restricted enabled_toolsets (e.g. [\"safe\"]) must\n+                            # NOT silently gain tools after a reload. This is the\n+                            # opposite of the interactive CLI/TUI /reload-mcp,\n+                            # which is a single user re-applying their own config\n+                            # edit; gateway agents are per-session and may be\n+                            # deliberately locked down. (Contract is asserted by\n+                            # test_reload_mcp_preserves_per_agent_toolset_overrides.)\n+                            refresh_agent_mcp_tools(_agent, quiet_mode=True)\n             except Exception as _exc:\n                 logger.debug(\n                     \"Failed to update cached agent tools after MCP reload: %s\","]},{"file":"hermes_cli/config.py","hunks":["@@ -1201,6 +1201,21 @@ def _ensure_hermes_home_managed(home: Path):\n     # 100K chars ≈ 25–35K tokens across typical tokenisers.\n     \"file_read_max_chars\": 100_000,\n \n+    # Seconds to wait at agent-build time for in-flight MCP server discovery\n+    # to finish before the agent snapshots its tool list.  MCP discovery runs\n+    # in a background thread so a slow/dead server can't freeze startup; this\n+    # bounds how long the first agent build blocks on it.  The wait returns\n+    # the INSTANT discovery completes, so users with no MCP servers (the common\n+    # case) or fast servers pay ~0s regardless of this value — the bound is\n+    # only reached when a server is genuinely still connecting.  The old 0.75s\n+    # default was a touch short for HTTP/OAuth servers on a cold connect; a\n+    # modest bump lets more of them land in the FIRST turn's snapshot.  This is\n+    # only a turn-1 latency/UX knob: a server that misses this window is still\n+    # picked up automatically on the next turn by the between-turns refresh\n+    # (see agent/turn_context.py), so correctness never depends on it.  Keep it\n+    # small so a slow/dead server adds little to first-response latency.\n+    \"mcp_discovery_timeout\": 1.5,\n+\n     # Tool-output truncation thresholds. When terminal output or a\n     # single read_file page exceeds these limits, Hermes truncates the\n     # payload sent to the model (keeping head + tail for terminal,"]},{"file":"hermes_cli/mcp_startup.py","hunks":["@@ -51,9 +51,38 @@ def _discover() -> None:\n         thread.start()\n \n \n-def wait_for_mcp_discovery(timeout: float = 0.75) -> None:\n-    \"\"\"Briefly wait for background MCP discovery before the first tool snapshot.\"\"\"\n+def _resolve_discovery_timeout(explicit: \"float | None\") -> float:\n+    \"\"\"Resolve the MCP discovery wait bound: explicit arg > config > default.\n+\n+    Reads ``mcp_discovery_timeout`` from config.yaml, defaulting to the value in\n+    ``DEFAULT_CONFIG`` (single source of truth) when the key is absent. Kept lazy\n+    and fail-safe — a missing/invalid value or a broken config falls back to a\n+    short safe bound so startup can never hang or crash.\n+    \"\"\"\n+    if explicit is not None:\n+        return explicit\n+    try:\n+        from hermes_cli.config import load_config, DEFAULT_CONFIG\n+\n+        default = float(DEFAULT_CONFIG.get(\"mcp_discovery_timeout\", 1.5))\n+        raw = (load_config() or {}).get(\"mcp_discovery_timeout\", default)\n+        val = float(raw)\n+        return val if val > 0 else default\n+    except Exception:\n+        return 1.5\n+\n+\n+def wait_for_mcp_discovery(timeout: \"float | None\" = None) -> None:\n+    \"\"\"Wait for background MCP discovery before the first tool snapshot.\n+\n+    ``thread.join(timeout)`` returns the INSTANT discovery completes, so this\n+    only ever blocks for the real connect time of a still-pending server —\n+    users with no MCP servers or fast servers pay ~0s.  The bound (from\n+    ``mcp_discovery_timeout`` in config) just caps the wait so a dead server\n+    can't freeze startup; servers that miss it are picked up by the automatic\n+    late-binding refresh.\n+    \"\"\"\n     thread = _mcp_discovery_thread\n     if thread is None or not thread.is_alive():\n         return\n-    thread.join(timeout=timeout)\n+    thread.join(timeout=_resolve_discovery_timeout(timeout))"]},{"file":"tests/agent/test_turn_context.py","hunks":["@@ -47,6 +47,9 @@ def __init__(self):\n         self.max_iterations = 90\n         self.tools = []\n         self.valid_tool_names = set()\n+        self.enabled_toolsets = None\n+        self.disabled_toolsets = None\n+        self._skip_mcp_refresh = False\n         self.compression_enabled = False\n         self.context_compressor = types.SimpleNamespace(\n             protect_first_n=2, protect_last_n=2","@@ -185,3 +188,74 @@ def test_no_review_when_memory_disabled():\n     agent = _FakeAgent()\n     ctx = _build(agent)\n     assert ctx.should_review_memory is False\n+\n+\n+# ── Between-turns MCP refresh (cache-safe late-binding) ──────────────────────\n+#\n+# A slow MCP server that connects after the agent's build-time tool snapshot\n+# must become callable by the user's NEXT turn — without mutating an in-flight\n+# turn's cached request prefix. The prologue is exactly that boundary, so the\n+# refresh hook lives here. These assert the contract (R1/R2/R6 in the spec),\n+# not timing permutations.\n+\n+\n+def test_between_turns_refresh_adds_late_tool_when_servers_registered():\n+    \"\"\"R1: a tool that registered since build lands in this turn's snapshot.\"\"\"\n+    agent = _FakeAgent()\n+\n+    new_def = {\"type\": \"function\", \"function\": {\"name\": \"mcp_x_tool\", \"description\": \"\", \"parameters\": {}}}\n+\n+    import model_tools\n+    with patch(\"tools.mcp_tool.has_registered_mcp_tools\", return_value=True), \\\n+         patch.object(model_tools, \"get_tool_definitions\", return_value=[new_def]):\n+        _build(agent)\n+\n+    assert \"mcp_x_tool\" in agent.valid_tool_names\n+    assert any(t[\"function\"][\"name\"] == \"mcp_x_tool\" for t in agent.tools)\n+\n+\n+def test_between_turns_refresh_skipped_when_no_servers():\n+    \"\"\"R6: the common case (no MCP servers) never walks the registry.\"\"\"\n+    agent = _FakeAgent()\n+    import model_tools\n+\n+    with patch(\"tools.mcp_tool.has_registered_mcp_tools\", return_value=False), \\\n+         patch.object(model_tools, \"get_tool_definitions\") as gtd:\n+        _build(agent)\n+\n+    gtd.assert_not_called()\n+\n+\n+def test_between_turns_refresh_skipped_when_skip_flag_set():\n+    \"\"\"Internal forks (background_review) set _skip_mcp_refresh to keep tools[]\n+    byte-identical to the parent for cache parity — the hook must honor it even\n+    when MCP servers are registered.\"\"\"\n+    agent = _FakeAgent()\n+    agent._skip_mcp_refresh = True\n+    import model_tools\n+\n+    with patch(\"tools.mcp_tool.has_registered_mcp_tools\", return_value=True), \\\n+         patch.object(model_tools, \"get_tool_definitions\") as gtd:\n+        _build(agent)\n+\n+    gtd.assert_not_called()\n+\n+\n+def test_between_turns_refresh_no_churn_when_unchanged():\n+    \"\"\"R2: an unchanged tool set leaves the snapshot object identity intact\n+    (no needless swap → nothing for the next request prefix to diff against).\"\"\"\n+    agent = _FakeAgent()\n+    same = [{\"type\": \"function\", \"function\": {\"name\": \"a\", \"description\": \"\", \"parameters\": {}}}]\n+    agent.tools = same\n+    agent.valid_tool_names = {\"a\"}\n+\n+    import model_tools\n+    with patch(\"tools.mcp_tool.has_registered_mcp_tools\", return_value=True), \\\n+         patch.object(\n+             model_tools, \"get_tool_definitions\",\n+             return_value=[{\"type\": \"function\", \"function\": {\"name\": \"a\", \"description\": \"\", \"parameters\": {}}}],\n+         ):\n+        _build(agent)\n+\n+    assert agent.tools is same  # not replaced → no churn\n+"]},{"file":"tests/tools/test_refresh_agent_mcp_tools.py","hunks":["@@ -0,0 +1,298 @@\n+\"\"\"Tests for the shared MCP agent-tool refresh helper and discovery-wait bound.\n+\n+``refresh_agent_mcp_tools`` is the single rebuild path used by the TUI\n+``reload.mcp`` RPC, the gateway reload, and the late-binding refresh thread —\n+so a slow MCP server that connects after the agent's one-time tool snapshot is\n+picked up everywhere identically.  These assert the *contracts* those callers\n+rely on (name-based diff, in-place mutation, agent-scoped filtering) rather than\n+freezing any particular tool list.\n+\"\"\"\n+\n+import threading\n+import types\n+\n+from tools import mcp_tool\n+\n+\n+def _tool(name):\n+    return {\"type\": \"function\", \"function\": {\"name\": name, \"description\": \"\", \"parameters\": {}}}\n+\n+\n+def _agent(tool_names, *, enabled=None, disabled=None):\n+    a = types.SimpleNamespace()\n+    a.tools = [_tool(n) for n in tool_names]\n+    a.valid_tool_names = set(tool_names)\n+    a.enabled_toolsets = enabled\n+    a.disabled_toolsets = disabled\n+    return a\n+\n+\n+def test_refresh_adds_late_landing_tools(monkeypatch):\n+    \"\"\"A server that registers after build → its tools land in the snapshot.\"\"\"\n+    agent = _agent([\"read_file\", \"terminal\"])\n+\n+    new_defs = [_tool(n) for n in (\"read_file\", \"terminal\", \"mcp_granola_get_account_info\")]\n+    monkeypatch.setattr(mcp_tool, \"get_tool_definitions\", lambda **kw: new_defs, raising=False)\n+    # get_tool_definitions is imported inside the helper from model_tools, so patch there too.\n+    import model_tools\n+    monkeypatch.setattr(model_tools, \"get_tool_definitions\", lambda **kw: new_defs)\n+\n+    added = mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    assert added == {\"mcp_granola_get_account_info\"}\n+    assert \"mcp_granola_get_account_info\" in agent.valid_tool_names\n+    assert len(agent.tools) == 3\n+\n+\n+def test_refresh_no_change_returns_empty_and_leaves_agent_untouched(monkeypatch):\n+    \"\"\"No new tools → empty set, and the snapshot object is not swapped.\"\"\"\n+    agent = _agent([\"read_file\", \"terminal\"])\n+    original_tools = agent.tools\n+\n+    import model_tools\n+    monkeypatch.setattr(\n+        model_tools, \"get_tool_definitions\",\n+        lambda **kw: [_tool(\"read_file\"), _tool(\"terminal\")],\n+    )\n+\n+    added = mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    assert added == set()\n+    assert agent.tools is original_tools  # not replaced → no churn / no cache thrash\n+\n+\n+def test_refresh_detects_equal_size_swap(monkeypatch):\n+    \"\"\"Name-based diff catches an add+remove of equal count (count-compare can't).\"\"\"\n+    agent = _agent([\"a\", \"old_mcp_tool\"])  # 2 tools\n+\n+    import model_tools\n+    # Same COUNT (2) but a different membership: old_mcp_tool removed, new added.\n+    monkeypatch.setattr(\n+        model_tools, \"get_tool_definitions\",\n+        lambda **kw: [_tool(\"a\"), _tool(\"new_mcp_tool\")],\n+    )\n+\n+    added = mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    assert added == {\"new_mcp_tool\"}\n+    assert agent.valid_tool_names == {\"a\", \"new_mcp_tool\"}\n+    assert \"old_mcp_tool\" not in agent.valid_tool_names\n+\n+\n+def test_refresh_passes_agent_toolset_filters(monkeypatch):\n+    \"\"\"The rebuild re-derives with the agent's OWN enabled/disabled toolsets.\"\"\"\n+    agent = _agent([\"a\"], enabled=[\"coding\", \"granola\"], disabled=[\"messaging\"])\n+    seen = {}\n+\n+    import model_tools\n+\n+    def _capture(**kw):\n+        seen.update(kw)\n+        return [_tool(\"a\"), _tool(\"b\")]\n+\n+    monkeypatch.setattr(model_tools, \"get_tool_definitions\", _capture)\n+\n+    mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    assert seen[\"enabled_toolsets\"] == [\"coding\", \"granola\"]\n+    assert seen[\"disabled_toolsets\"] == [\"messaging\"]\n+\n+\n+def test_refresh_preserves_memory_provider_and_context_engine_tools(monkeypatch):\n+    \"\"\"B1 regression: a rebuild must NOT drop post-build-injected tools.\n+\n+    get_tool_definitions() returns only the registry-derived tools. agent_init\n+    appends memory-provider tools (mem0/honcho/…) and context-engine tools\n+    (lcm_*) directly onto agent.tools AFTER that. A naive\n+    `agent.tools = get_tool_definitions()` would silently delete them on every\n+    refresh. The helper must re-inject them.\n+    \"\"\"\n+    # Agent already carries: a built-in, a memory-provider tool, a context tool.\n+    agent = _agent([\"read_file\", \"memory_search\", \"lcm_grep\"])\n+\n+    # Provider exposes its schemas; context compressor exposes lcm_*.\n+    agent._memory_manager = types.SimpleNamespace(\n+        get_all_tool_schemas=lambda: [\n+            {\"name\": \"memory_search\", \"description\": \"\", \"parameters\": {}}\n+        ]\n+    )\n+    agent.context_compressor = types.SimpleNamespace(\n+        get_tool_schemas=lambda: [\n+            {\"name\": \"lcm_grep\", \"description\": \"\", \"parameters\": {}}\n+        ]\n+    )\n+    agent._context_engine_tool_names = {\"lcm_grep\"}\n+\n+    import model_tools\n+    # The registry now ALSO has a newly-connected MCP tool, but does NOT contain\n+    # the memory/context tools (they're never in get_tool_definitions output).\n+    monkeypatch.setattr(\n+        model_tools, \"get_tool_definitions\",\n+        lambda **kw: [_tool(\"read_file\"), _tool(\"mcp_new_server_tool\")],\n+    )\n+\n+    added = mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    # The new MCP tool landed AND the injected families survived.\n+    assert \"mcp_new_server_tool\" in agent.valid_tool_names\n+    assert \"memory_search\" in agent.valid_tool_names   # not clobbered\n+    assert \"lcm_grep\" in agent.valid_tool_names         # not clobbered\n+    assert added == {\"mcp_new_server_tool\"}\n+\n+\n+def test_refresh_respects_context_engine_toolset_gate(monkeypatch):\n+    \"\"\"#5544: context-engine tools must NOT be re-injected on a restricted\n+    toolset. A platform with enabled_toolsets that excludes context_engine\n+    must not get lcm_* leaked back in by a refresh.\"\"\"\n+    agent = _agent([\"read_file\"], enabled=[\"coding\"])  # context_engine NOT enabled\n+    agent.context_compressor = types.SimpleNamespace(\n+        get_tool_schemas=lambda: [{\"name\": \"lcm_grep\", \"description\": \"\", \"parameters\": {}}]\n+    )\n+    agent._context_engine_tool_names = set()\n+\n+    import model_tools\n+    monkeypatch.setattr(\n+        model_tools, \"get_tool_definitions\",\n+        lambda **kw: [_tool(\"read_file\"), _tool(\"mcp_new_tool\")],\n+    )\n+\n+    mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    assert \"mcp_new_tool\" in agent.valid_tool_names  # MCP tool still lands\n+    assert \"lcm_grep\" not in agent.valid_tool_names   # gated out (#5544)\n+\n+\n+def test_refreshed_tool_is_callable_through_valid_tool_names_guard(monkeypatch):\n+    \"\"\"The whole point: a late tool, once refreshed, passes the name guard the\n+    run loop uses to accept/reject tool calls (agent.valid_tool_names).\"\"\"\n+    agent = _agent([\"read_file\"])\n+\n+    import model_tools\n+    monkeypatch.setattr(\n+        model_tools, \"get_tool_definitions\",\n+        lambda **kw: [_tool(\"read_file\"), _tool(\"mcp_granola_list_meetings\")],\n+    )\n+\n+    # Before refresh the run loop would reject the call (\"Tool does not exist\").\n+    assert \"mcp_granola_list_meetings\" not in agent.valid_tool_names\n+\n+    mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    # After refresh the same guard accepts it AND it's in the tools= payload.\n+    assert \"mcp_granola_list_meetings\" in agent.valid_tool_names\n+    assert any(t[\"function\"][\"name\"] == \"mcp_granola_list_meetings\" for t in agent.tools)\n+\n+\n+def test_refresh_is_thread_safe_under_concurrent_calls(monkeypatch):\n+    \"\"\"Concurrent refreshes keep tools / valid_tool_names coherent.\n+\n+    The registry alternates between two DIFFERENT tool sets every call, so the\n+    write path (publish) runs repeatedly rather than short-circuiting on the\n+    no-change early return — this actually exercises the lock. The invariant:\n+    a reader of ``valid_tool_names`` must always match ``agent.tools``, and the\n+    final published pair must be one of the two valid sets (never a mix).\n+    \"\"\"\n+    agent = _agent([\"a\"])\n+\n+    import itertools\n+    set_a = [_tool(\"a\"), _tool(\"b\")]\n+    set_b = [_tool(\"a\"), _tool(\"c\")]\n+    flip = itertools.cycle([set_a, set_b])\n+    flip_lock = threading.Lock()\n+\n+    def _gtd(**kw):\n+        with flip_lock:\n+            return list(next(flip))\n+\n+    import model_tools\n+    monkeypatch.setattr(model_tools, \"get_tool_definitions\", _gtd)\n+\n+    errors = []\n+\n+    def _worker():\n+        try:\n+            for _ in range(50):\n+                mcp_tool.refresh_agent_mcp_tools(agent)\n+                # Coherence invariant: the name set must match the tool list\n+                # at every observation, never a torn cross-attribute state.\n+                names = {t[\"function\"][\"name\"] for t in agent.tools}\n+                assert agent.valid_tool_names == names\n+                assert names in ({\"a\", \"b\"}, {\"a\", \"c\"})\n+        except Exception as exc:  # pragma: no cover - failure path\n+            errors.append(exc)\n+\n+    threads = [threading.Thread(target=_worker) for _ in range(4)]\n+    for t in threads:\n+        t.start()\n+    for t in threads:\n+        t.join(timeout=10)\n+\n+    assert not errors\n+    assert agent.valid_tool_names in ({\"a\", \"b\"}, {\"a\", \"c\"})\n+\n+\n+# ── discovery-wait bound (mcp_discovery_timeout config) ──────────────────────\n+\n+\n+def test_resolve_discovery_timeout_explicit_wins(monkeypatch):\n+    from hermes_cli import mcp_startup\n+\n+    assert mcp_startup._resolve_discovery_timeout(2.5) == 2.5\n+\n+\n+def test_resolve_discovery_timeout_reads_config(monkeypatch):\n+    from hermes_cli import mcp_startup\n+    import hermes_cli.config as cfg\n+\n+    monkeypatch.setattr(cfg, \"load_config\", lambda: {\"mcp_discovery_timeout\": 8.0})\n+\n+    assert mcp_startup._resolve_discovery_timeout(None) == 8.0\n+\n+\n+def test_resolve_discovery_timeout_falls_back_on_bad_value(monkeypatch):\n+    from hermes_cli import mcp_startup\n+    import hermes_cli.config as cfg\n+\n+    # Non-positive / unparsable → DEFAULT_CONFIG value, never hang.\n+    default = float(cfg.DEFAULT_CONFIG.get(\"mcp_discovery_timeout\", 1.5))\n+    monkeypatch.setattr(cfg, \"load_config\", lambda: {\"mcp_discovery_timeout\": 0})\n+    assert mcp_startup._resolve_discovery_timeout(None) == default\n+\n+    monkeypatch.setattr(cfg, \"load_config\", lambda: {\"mcp_discovery_timeout\": \"oops\"})\n+    assert mcp_startup._resolve_discovery_timeout(None) == default\n+\n+\n+def test_stale_generation_refresh_does_not_clobber_newer(monkeypatch):\n+    \"\"\"A slower refresh that computed an OLDER registry generation must not\n+    overwrite a snapshot a newer-generation refresh already published.\"\"\"\n+    from tools import registry as _reg_mod\n+\n+    agent = _agent([\"read_file\"])\n+    # A newer refresh already published generation = current+5, with two tools.\n+    agent._tool_snapshot_generation = _reg_mod.registry._generation + 5\n+    agent.tools = [_tool(\"read_file\"), _tool(\"mcp_new_tool\")]\n+    agent.valid_tool_names = {\"read_file\", \"mcp_new_tool\"}\n+\n+    import model_tools\n+    # This (stale) refresh computes only the old single-tool set.\n+    monkeypatch.setattr(model_tools, \"get_tool_definitions\", lambda **kw: [_tool(\"read_file\")])\n+\n+    added = mcp_tool.refresh_agent_mcp_tools(agent)\n+\n+    # Stale write rejected: the newer tool survives.\n+    assert added == set()\n+    assert \"mcp_new_tool\" in agent.valid_tool_names\n+\n+\n+def test_wait_returns_instantly_when_no_discovery_thread(monkeypatch):\n+    \"\"\"The common case (no MCP / discovery done) pays ~0s regardless of bound.\"\"\"\n+    import time\n+    from hermes_cli import mcp_startup\n+\n+    monkeypatch.setattr(mcp_startup, \"_mcp_discovery_thread\", None)\n+    import hermes_cli.config as cfg\n+    monkeypatch.setattr(cfg, \"load_config\", lambda: {\"mcp_discovery_timeout\": 999.0})\n+\n+    t0 = time.time()\n+    mcp_startup.wait_for_mcp_discovery()\n+    assert time.time() - t0 < 0.2  # never blocks on the bound when nothing's pending"]},{"file":"tools/mcp_tool.py","hunks":["@@ -3994,6 +3994,215 @@ async def _probe_all():\n     return result\n \n \n+# Serializes in-place mutation of an agent's tool snapshot.  The reload RPC,\n+# the gateway reload, and the late-binding refresh thread all swap\n+# ``agent.tools`` / ``agent.valid_tool_names`` after the agent was built; the\n+# agent's run loop reads those during tool iteration, so a concurrent write\n+# mid-read could otherwise expose a half-updated list.\n+_agent_tools_lock = threading.Lock()\n+\n+\n+def has_registered_mcp_tools() -> bool:\n+    \"\"\"True if any MCP server has actually registered tools into the registry.\n+\n+    Cheap — checks the global MCP-tool→server name map under ``_lock``, no\n+    registry walk.  Used by the per-turn refresh hook so a session with no MCP\n+    tools (the common case, and also a connected-but-zero-tool/prompt-only\n+    server) skips the ``get_tool_definitions`` rebuild entirely.  Checks\n+    registered TOOLS, not connected servers, so a server that registers no tools\n+    doesn't keep the hook firing every turn.\n+    \"\"\"\n+    with _lock:\n+        return bool(_mcp_tool_server_names)\n+\n+\n+def refresh_agent_mcp_tools(\n+    agent,\n+    *,\n+    enabled_override=None,\n+    disabled_override=None,\n+    quiet_mode: bool = True,\n+) -> set:\n+    \"\"\"Re-derive an already-built agent's tool snapshot from the live registry.\n+\n+    The agent snapshots ``agent.tools`` once at build time and never re-reads\n+    the registry (see ``run_agent`` / ``agent_init``).  When MCP servers connect\n+    *after* that snapshot — a slow HTTP/OAuth server that misses the bounded\n+    startup wait, or a ``/reload-mcp`` — their tools are invisible until the\n+    snapshot is rebuilt.  This is the single shared rebuild used by every such\n+    caller (the TUI ``reload.mcp`` RPC, the gateway reload, the late-binding\n+    refresh thread, and the per-turn between-turns refresh) so they can't drift\n+    apart again.\n+\n+    The rebuild respects the agent's own ``enabled_toolsets`` /\n+    ``disabled_toolsets`` (the same filtering it was built with) and diffs by\n+    tool **name** (not count — a count compare misses an equal-size add/remove\n+    swap).\n+\n+    Crucially it is **additive-preserving**: ``get_tool_definitions`` returns\n+    only the registry-derived tools, but ``agent_init`` appends two further\n+    families directly onto ``agent.tools`` *after* that — external\n+    memory-provider tools (mem0/honcho/…) and context-engine tools\n+    (``lcm_*``).  A naive ``agent.tools = get_tool_definitions(...)`` would\n+    silently DELETE those.  So after rebuilding the registry set we re-run the\n+    same post-build injectors ``agent_init`` used, reconstructing the full\n+    surface.  The new ``(tools, valid_tool_names)`` pair is published together\n+    under ``_agent_tools_lock`` so a concurrent reader never sees a\n+    cross-attribute half-swap.\n+\n+    Returns the set of newly-added tool names (empty when nothing changed), so\n+    callers can decide whether to notify the user / re-emit session info.  The\n+    caller owns the prompt-cache contract: this helper does NOT check turn state,\n+    because each caller has a different policy (``/reload-mcp`` rebuilds after\n+    explicit user consent; the late-binding and between-turns paths only rebuild\n+    at a turn boundary, before that turn's ``tools=`` prefix is assembled).\n+    \"\"\"\n+    from model_tools import get_tool_definitions\n+    from tools.registry import registry\n+\n+    # Explicit reloads (/reload-mcp) pass freshly-resolved toolsets so a server\n+    # the user just ENABLED in config is picked up; the agent's stored selection\n+    # is then updated to match. The automatic paths (between-turns, late-binding)\n+    # pass nothing and reuse the agent's build-time selection unchanged.\n+    if enabled_override is not None or disabled_override is not None:\n+        enabled = enabled_override if enabled_override is not None else getattr(agent, \"enabled_toolsets\", None)\n+        disabled = disabled_override if disabled_override is not None else getattr(agent, \"disabled_toolsets\", None)\n+        agent.enabled_toolsets = enabled\n+        agent.disabled_toolsets = disabled\n+    else:\n+        enabled = getattr(agent, \"enabled_toolsets\", None)\n+        disabled = getattr(agent, \"disabled_toolsets\", None)\n+\n+    # Capture the registry generation this rebuild is derived from BEFORE the\n+    # (potentially slow) get_tool_definitions call. Used at publish time to\n+    # reject a stale write: if two callers race (e.g. the late-refresh daemon\n+    # and the between-turns prologue around turn 1), a slower caller that\n+    # computed an OLDER set must not clobber a newer set another caller already\n+    # published. ``registry._generation`` bumps on every (de)register.\n+    snapshot_generation = registry._generation\n+\n+    # Registry-derived tools (built-ins + MCP), filtered to the agent's toolsets.\n+    # Computed OUTSIDE the lock (get_tool_definitions can be slow); the diff and\n+    # publish below happen together in ONE critical section so two concurrent\n+    # callers can't torn-publish or compute overlapping ``added`` sets.\n+    new_defs = list(\n+        get_tool_definitions(\n+            enabled_toolsets=enabled,\n+            disabled_toolsets=disabled,\n+            quiet_mode=quiet_mode,\n+        )\n+        or []\n+    )\n+    new_names = {t[\"function\"][\"name\"] for t in new_defs}\n+\n+    # Re-append the post-build injected families that get_tool_definitions does\n+    # NOT reproduce, so a refresh never strips them (memory-provider + context-\n+    # engine tools). Staged entirely on LOCALS — the live ``agent.tools`` /\n+    # ``valid_tool_names`` / ``_context_engine_tool_names`` are never touched\n+    # until the single atomic publish below, so a concurrent reader\n+    # (``build_api_kwargs``) can't see a partial rebuild or a cross-attribute\n+    # half-swap. ``staged_engine_names`` are the context-engine routing names\n+    # this rebuild actually appended (matching agent_init's dedup-aware add).\n+    staged_engine_names = _reinject_post_build_tools(agent, new_defs, new_names)\n+\n+    # Single atomic read-diff-publish so the returned ``added`` is consistent\n+    # with what was actually published, even under concurrent callers, and a\n+    # stale (older-generation) rebuild can't overwrite a newer published one.\n+    with _agent_tools_lock:\n+        # Defensive: the published generation should be an int, but tolerate an\n+        # agent that never set it (or set a non-int, e.g. a test mock) rather\n+        # than throwing TypeError on the comparison and silently failing the\n+        # whole refresh.\n+        published_gen_raw = getattr(agent, \"_tool_snapshot_generation\", -1)\n+        published_gen = published_gen_raw if isinstance(published_gen_raw, int) else -1\n+        if snapshot_generation < published_gen:\n+            # A newer snapshot already won; our set is stale — drop it.\n+            return set()\n+        current = {\n+            t[\"function\"][\"name\"]\n+            for t in (getattr(agent, \"tools\", None) or [])\n+        }\n+        if new_names == current:\n+            # No change → leave the live snapshot untouched (no churn), but\n+            # record the generation so an in-flight older caller can't clobber.\n+            agent._tool_snapshot_generation = max(published_gen, snapshot_generation)\n+            return set()\n+        agent.tools = new_defs\n+        agent.valid_tool_names = new_names\n+        # Publish context-engine routing names atomically with the snapshot.\n+        engine_names = getattr(agent, \"_context_engine_tool_names\", None)\n+        if isinstance(engine_names, set):\n+            engine_names.clear()\n+            engine_names.update(staged_engine_names)\n+        agent._tool_snapshot_generation = max(published_gen, snapshot_generation)\n+        return new_names - current\n+\n+\n+def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set:\n+    \"\"\"Append memory-provider and context-engine tools onto staged locals.\n+\n+    Mirrors the post-``get_tool_definitions`` injection in ``agent_init`` so a\n+    snapshot rebuild reconstructs the FULL tool surface, not just the\n+    registry-derived subset. Operates ONLY on the caller's staged ``tools_list``\n+    / ``name_set`` (never the live agent attributes) so the rebuild stays atomic.\n+    Idempotent (skips names already present) and fail-soft.\n+\n+    Returns the set of context-engine routing names actually appended by THIS\n+    rebuild — matching ``agent_init``'s dedup behavior (a name already provided\n+    by a registry/plugin tool is NOT claimed for context-engine routing). The\n+    caller publishes this into ``agent._context_engine_tool_names`` atomically\n+    with the snapshot.\n+    \"\"\"\n+    def _add(schema: dict) -> bool:\n+        name = schema.get(\"name\", \"\")\n+        if not name or name in name_set:\n+            return False\n+        tools_list.append({\"type\": \"function\", \"function\": schema})\n+        name_set.add(name)\n+        return True\n+\n+    # Memory-provider tools (mem0/honcho/byterover/supermemory/…).\n+    try:\n+        memory_manager = getattr(agent, \"_memory_manager\", None)\n+        get_mem_schemas = getattr(memory_manager, \"get_all_tool_schemas\", None) if memory_manager else None\n+        if callable(get_mem_schemas):\n+            # Honor the same enablement gate inject_memory_provider_tools uses.\n+            from agent.memory_manager import memory_provider_tools_enabled\n+            if \"memory\" in name_set or memory_provider_tools_enabled(getattr(agent, \"enabled_toolsets\", None)):\n+                for schema in get_mem_schemas():\n+                    if isinstance(schema, dict):\n+                        _add(schema)\n+    except Exception:\n+        logger.debug(\"Memory-provider tool re-injection skipped\", exc_info=True)\n+\n+    # Context-engine tools (lcm_grep/lcm_describe/…) — the `context_engine`\n+    # toolset is intentionally empty, so these only exist via this append.\n+    # Honor the same enabled_toolsets gate agent_init uses (#5544): without it a\n+    # restricted-toolset platform (e.g. platform_toolsets: telegram: []) would\n+    # re-leak lcm_* tools the build deliberately excluded, and pay the local-\n+    # model latency penalty.\n+    staged_engine_names: set = set()\n+    try:\n+        enabled = getattr(agent, \"enabled_toolsets\", None)\n+        context_engine_allowed = enabled is None or \"context_engine\" in enabled\n+        compressor = getattr(agent, \"context_compressor\", None)\n+        get_schemas = getattr(compressor, \"get_tool_schemas\", None) if compressor else None\n+        if context_engine_allowed and callable(get_schemas):\n+            for schema in get_schemas():\n+                if not isinstance(schema, dict):\n+                    continue\n+                name = schema.get(\"name\", \"\")\n+                # Only claim the routing name when WE appended the schema, so a\n+                # name already owned by a registry/plugin tool keeps its own\n+                # dispatch (matches agent_init.py's `continue`-before-claim).\n+                if _add(schema) and name:\n+                    staged_engine_names.add(name)\n+    except Exception:\n+        logger.debug(\"Context-engine tool re-injection skipped\", exc_info=True)\n+\n+    return staged_engine_names\n+\n+\n def shutdown_mcp_servers():\n     \"\"\"Close all MCP server connections and stop the background loop.\n "]},{"file":"tui_gateway/entry.py","hunks":["@@ -192,22 +192,32 @@ def _log_exit(reason: str) -> None:\n     print(f\"[gateway-exit] {reason}\", file=sys.stderr, flush=True)\n \n \n-def wait_for_mcp_discovery(timeout: float = 0.75) -> None:\n-    \"\"\"Briefly block until background MCP discovery finishes, up to ``timeout``.\n+def wait_for_mcp_discovery(timeout: \"float | None\" = None) -> None:\n+    \"\"\"Block until background MCP discovery finishes, up to the resolved bound.\n \n     MCP discovery runs in a daemon thread spawned at startup (see main()) so a\n     slow/dead server can't freeze ``gateway.ready``.  But the agent snapshots\n     its tool list ONCE at build time and never re-reads it, so a reachable-but-\n     slow server that finishes connecting *after* the first prompt would be\n-    invisible for the whole session.  Joining with a short bounded timeout\n-    before the first agent build lets already-spawning fast servers land\n-    without re-introducing the startup hang: a dead server simply isn't waited\n-    on beyond ``timeout``.  No-op when no discovery thread was started.\n+    invisible for the whole session.  Joining with a bounded timeout before the\n+    first agent build lets already-spawning servers land without re-introducing\n+    the startup hang: ``thread.join(timeout)`` returns the instant discovery\n+    completes (so fast/no-MCP startups pay ~0s), and a dead server is simply not\n+    waited on beyond the bound.  No-op when no discovery thread was started.\n+\n+    The bound comes from ``mcp_discovery_timeout`` in config (shared with the\n+    CLI path via ``hermes_cli.mcp_startup``); ``timeout`` overrides it.\n     \"\"\"\n     thread = _mcp_discovery_thread\n     if thread is None or not thread.is_alive():\n         return\n-    thread.join(timeout=timeout)\n+    try:\n+        from hermes_cli.mcp_startup import _resolve_discovery_timeout\n+\n+        bound = _resolve_discovery_timeout(timeout)\n+    except Exception:\n+        bound = timeout if timeout is not None else 0.75\n+    thread.join(timeout=bound)\n \n \n def mcp_discovery_in_flight() -> bool:"]},{"file":"tui_gateway/server.py","hunks":["@@ -3557,26 +3557,19 @@ def _wait_then_refresh() -> None:\n             ):\n                 return\n             try:\n-                from model_tools import get_tool_definitions\n+                from tools.mcp_tool import refresh_agent_mcp_tools\n \n-                new_defs = get_tool_definitions(\n-                    enabled_toolsets=_load_enabled_toolsets(),\n-                    quiet_mode=True,\n-                )\n+                added = refresh_agent_mcp_tools(agent, quiet_mode=True)\n             except Exception as exc:\n                 logger.warning(\n-                    \"Late MCP refresh: get_tool_definitions failed for %s: %s\",\n+                    \"Late MCP refresh: tool snapshot rebuild failed for %s: %s\",\n                     sid,\n                     exc,\n                 )\n                 return\n-            # No change (discovery added nothing new) → don't churn the client.\n-            if len(new_defs or []) == len(getattr(agent, \"tools\", []) or []):\n+            # No new tools landed (discovery added nothing) → don't churn the client.\n+            if not added:\n                 return\n-            agent.tools = new_defs\n-            agent.valid_tool_names = (\n-                {t[\"function\"][\"name\"] for t in new_defs} if new_defs else set()\n-            )\n             info = _session_info(agent, session)\n         # Emit outside the lock — write_json must not block under _sessions_lock.\n         _emit(\"session.info\", sid, info)","@@ -8414,16 +8407,15 @@ def _(rid, params: dict) -> dict:\n             # The user already consented to the prompt-cache invalidation via\n             # the confirm gate above.  Mirrors gateway/run.py::_execute_mcp_reload.\n             try:\n-                from model_tools import get_tool_definitions\n+                from tools.mcp_tool import refresh_agent_mcp_tools\n \n-                new_defs = get_tool_definitions(\n-                    enabled_toolsets=_load_enabled_toolsets(),\n+                # Explicit reload: re-resolve enabled toolsets so a server the\n+                # user just enabled in config this session is picked up.\n+                refresh_agent_mcp_tools(\n+                    agent,\n+                    enabled_override=_load_enabled_toolsets(),\n                     quiet_mode=True,\n                 )\n-                agent.tools = new_defs\n-                agent.valid_tool_names = (\n-                    {t[\"function\"][\"name\"] for t in new_defs} if new_defs else set()\n-                )\n             except Exception as _exc:\n                 logger.warning(\n                     \"Failed to refresh cached agent tools after /reload-mcp: %s\",\n"]}]},{"kind":"pr","number":51322,"title":"fix(cli): wait full MCP cold-start in single-query mode (closes #51316)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54695,"title":"fix(mcp): let one-shot kanban workers await large MCP discovery before snapshot","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":57234,"title":"fix(agent): wait for MCP discovery before init_agent tool snapshot (#57170)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57170","number":57170}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":60017,"title":"fix(cli): wait for MCP before first-turn agent builds","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#61944","number":61944}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":68162,"title":"fix(cli): wait for background MCP discovery before building the one-shot agent (#68137)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3572d4bca","number":0},{"kind":"pr","value":"#68137","number":68137}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":68168,"title":"fix: wait for MCP discovery in one-shot mode before building agent (#68137)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68162","number":68162}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":71277,"title":"fix: wait for MCP discovery in one-shot mode before building agent (#68137)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68162","number":68162}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":75933,"title":"fix(mcp): ensure MCP discovery completes before agent build in non-interactive sessions","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","hermes_cli/cli_agent_setup_mixin.py","hermes_cli/config_defaults.py","hermes_cli/mcp_startup.py","hermes_cli/oneshot.py","tests/hermes_cli/test_mcp_discovery_timing.py"]},{"kind":"pr","number":107205,"title":"fix(mcp): increase discovery timeout and sync-wait in TUI agent build","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}