{"repo":"NousResearch/hermes-agent","public_id":"35927","display_label":"Suppress OAuth prompts freezing MCP background discovery","summary":"These issues and PRs address a bug where interactive OAuth stdin prompts during background MCP discovery cause the Hermes TUI to freeze. The fix suppresses such prompts in non-interactive background contexts.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53294,"merged_at":"2026-06-26T23:29:24+00:00"},"resolution":{"state":"resolved","by":"pr-53294"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T23:29:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":35927,"pr_number":53294,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-26T23:29:24+00:00"}],"members":[{"kind":"issue","number":35927,"title":"[Bug]: failed mcp cause hermes tui start freeze","url":null,"state":"closed"},{"kind":"pr","number":35945,"title":"fix(mcp): suppress oauth stdin prompts during background discovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e55ddc3e33b28cf036371b8d81c73109521ac3f1","number":0},{"kind":"pr","value":"#35945","number":35945}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53294,"title":"fix(mcp): suppress interactive OAuth stdin prompts during background discovery (#35927)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/mcp_startup.py","tests/hermes_cli/test_mcp_startup.py","tests/tools/test_mcp_oauth.py","tools/mcp_oauth.py","tui_gateway/entry.py"],"fix_hunks":[{"file":"hermes_cli/mcp_startup.py","hunks":["@@ -3,6 +3,7 @@\n from __future__ import annotations\n \n import threading\n+from contextlib import nullcontext\n from typing import Optional\n \n _mcp_discovery_lock = threading.Lock()","@@ -36,9 +37,7 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None:\n \n         def _discover() -> None:\n             try:\n-                from tools.mcp_tool import discover_mcp_tools\n-\n-                discover_mcp_tools()\n+                _discover_mcp_tools_without_interactive_oauth()\n             except Exception:\n                 logger.debug(\"Background MCP tool discovery failed\", exc_info=True)\n ","@@ -72,6 +71,19 @@ def _resolve_discovery_timeout(explicit: \"float | None\") -> float:\n         return 1.5\n \n \n+def _discover_mcp_tools_without_interactive_oauth() -> None:\n+    \"\"\"Run MCP discovery without letting OAuth read from the user's stdin.\"\"\"\n+    try:\n+        from tools.mcp_oauth import suppress_interactive_oauth\n+    except Exception:\n+        suppress_interactive_oauth = nullcontext\n+\n+    with suppress_interactive_oauth():\n+        from tools.mcp_tool import discover_mcp_tools\n+\n+        discover_mcp_tools()\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 "]},{"file":"tests/hermes_cli/test_mcp_startup.py","hunks":["@@ -3,6 +3,7 @@\n from __future__ import annotations\n \n from argparse import Namespace\n+from contextlib import nullcontext\n import sys\n import threading\n import time","@@ -70,6 +71,16 @@ def _blocking_discover():\n         \"agent.shell_hooks\",\n         types.SimpleNamespace(register_from_config=lambda *_a, **_k: None),\n     )\n+    # Stub mcp_oauth so the background thread doesn't pay the real (cold,\n+    # ~0.75s) ``tools.mcp_oauth`` import before calling discovery. This test\n+    # asserts the *backgrounding contract* (main thread returns fast, discovery\n+    # runs off-thread), not OAuth suppression — the unrelated import latency\n+    # would otherwise blow the polling deadline on a loaded CI runner.\n+    monkeypatch.setitem(\n+        sys.modules,\n+        \"tools.mcp_oauth\",\n+        types.SimpleNamespace(suppress_interactive_oauth=lambda: nullcontext()),\n+    )\n     monkeypatch.setitem(\n         sys.modules,\n         \"tools.mcp_tool\",","@@ -81,6 +92,9 @@ def _blocking_discover():\n         main_mod._prepare_agent_startup(_agent_args())\n         elapsed = time.monotonic() - start\n         assert elapsed < 0.2\n+        deadline = time.monotonic() + 3.0\n+        while calls[\"mcp\"] == 0 and time.monotonic() < deadline:\n+            time.sleep(0.01)\n         assert calls[\"mcp\"] == 1\n         assert mcp_startup._mcp_discovery_thread is not None\n         assert mcp_startup._mcp_discovery_thread.is_alive()","@@ -88,6 +102,50 @@ def _blocking_discover():\n         stop.set()\n \n \n+def test_background_mcp_discovery_suppresses_interactive_oauth(monkeypatch):\n+    state = {\"active\": False, \"during_discover\": None}\n+\n+    class SuppressInteractiveOAuth:\n+        def __enter__(self):\n+            state[\"active\"] = True\n+\n+        def __exit__(self, *_exc):\n+            state[\"active\"] = False\n+\n+    def _discover():\n+        state[\"during_discover\"] = state[\"active\"]\n+\n+    monkeypatch.setitem(\n+        sys.modules,\n+        \"hermes_cli.config\",\n+        types.SimpleNamespace(\n+            read_raw_config=lambda: {\"mcp_servers\": {\"demo\": {\"url\": \"https://mcp.example.test/mcp\"}}},\n+        ),\n+    )\n+    monkeypatch.setitem(\n+        sys.modules,\n+        \"tools.mcp_oauth\",\n+        types.SimpleNamespace(\n+            suppress_interactive_oauth=lambda: SuppressInteractiveOAuth(),\n+        ),\n+    )\n+    monkeypatch.setitem(\n+        sys.modules,\n+        \"tools.mcp_tool\",\n+        types.SimpleNamespace(discover_mcp_tools=_discover),\n+    )\n+\n+    mcp_startup.start_background_mcp_discovery(\n+        logger=types.SimpleNamespace(debug=lambda *_a, **_k: None),\n+        thread_name=\"test-mcp-discovery\",\n+    )\n+    assert mcp_startup._mcp_discovery_thread is not None\n+    mcp_startup._mcp_discovery_thread.join(timeout=1.0)\n+\n+    assert state[\"during_discover\"] is True\n+    assert state[\"active\"] is False\n+\n+\n def test_prepare_agent_startup_skips_mcp_bootstrap_for_tui_chat(monkeypatch):\n     calls = {\"mcp\": 0}\n "]},{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -466,6 +466,63 @@ def test_false_when_stdin_has_no_isatty(self, monkeypatch):\n         monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n         assert _is_interactive() is False\n \n+    def test_suppress_interactive_oauth_disables_stdin_prompts(self, monkeypatch):\n+        import tools.mcp_oauth as mod\n+\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = True\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+\n+        assert _is_interactive() is True\n+        with mod.suppress_interactive_oauth():\n+            assert _is_interactive() is False\n+        assert _is_interactive() is True\n+\n+    def test_suppression_propagates_across_run_coroutine_threadsafe(self, monkeypatch):\n+        \"\"\"#35927 core: suppression set on the discovery thread MUST reach the\n+        coroutine asyncio runs on a *different* (event-loop) thread — that is\n+        where the OAuth callback / _is_interactive() actually executes via\n+        run_coroutine_threadsafe. A threading.local would NOT propagate here\n+        (the original fix's defect); a ContextVar does.\"\"\"\n+        import asyncio\n+        import threading\n+        import tools.mcp_oauth as mod\n+\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = True\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+\n+        loop = asyncio.new_event_loop()\n+        loop_thread = threading.Thread(target=loop.run_forever, daemon=True)\n+        loop_thread.start()\n+        result = {}\n+        try:\n+            async def _probe_on_loop_thread():\n+                # runs on the loop thread, NOT the one that set suppression\n+                return (threading.current_thread() is not discovery_thread,\n+                        _is_interactive())\n+\n+            discovery_thread = None\n+\n+            def _discovery():\n+                nonlocal discovery_thread\n+                discovery_thread = threading.current_thread()\n+                with mod.suppress_interactive_oauth():\n+                    fut = asyncio.run_coroutine_threadsafe(\n+                        _probe_on_loop_thread(), loop\n+                    )\n+                    result[\"cross_thread\"], result[\"interactive\"] = fut.result(timeout=5)\n+\n+            dt = threading.Thread(target=_discovery)\n+            dt.start()\n+            dt.join()\n+        finally:\n+            loop.call_soon_threadsafe(loop.stop)\n+\n+        assert result[\"cross_thread\"] is True, \"probe must run on the loop thread\"\n+        # The whole point: suppression must hold on the loop thread.\n+        assert result[\"interactive\"] is False\n+\n \n class TestWaitForCallbackNoBlocking:\n     \"\"\"_wait_for_callback() must never call input() — it raises instead.\"\"\"","@@ -753,6 +810,26 @@ async def instant_sleep(_):\n         err = capsys.readouterr().err\n         assert \"paste the redirect URL\" not in err\n \n+    def test_paste_prompt_NOT_shown_when_interactivity_suppressed(self, monkeypatch, capsys):\n+        \"\"\"Background MCP discovery must not race the CLI/TUI stdin reader.\"\"\"\n+        import tools.mcp_oauth as mod\n+\n+        mod._oauth_port = _find_free_port()\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = True\n+        monkeypatch.setattr(mod.sys, \"stdin\", mock_stdin)\n+\n+        async def instant_sleep(_):\n+            pass\n+\n+        with patch.object(mod.asyncio, \"sleep\", instant_sleep):\n+            with mod.suppress_interactive_oauth():\n+                with pytest.raises(OAuthNonInteractiveError):\n+                    asyncio.run(_wait_for_callback())\n+        err = capsys.readouterr().err\n+        assert \"paste the redirect URL\" not in err\n+        mock_stdin.readline.assert_not_called()\n+\n \n class TestPasteCallbackSkipToken:\n     \"\"\"User can type `skip` (or similar) at the paste prompt to bail out.\"\"\""]},{"file":"tools/mcp_oauth.py","hunks":["@@ -33,6 +33,7 @@\n \"\"\"\n \n import asyncio\n+import contextvars\n import json\n import logging\n import os","@@ -44,6 +45,7 @@\n import threading\n import time\n import webbrowser\n+from contextlib import contextmanager\n from http.server import BaseHTTPRequestHandler, HTTPServer\n from pathlib import Path\n from typing import Any","@@ -92,6 +94,15 @@ class OAuthNonInteractiveError(RuntimeError):\n # Port used by the most recent build_oauth_auth() call.  Exposed so that\n # tests can verify the callback server and the redirect_uri share a port.\n _oauth_port: int | None = None\n+# Interactivity gate for OAuth stdin prompts. A ContextVar (NOT threading.local)\n+# is required: background MCP discovery sets this on the discovery thread, but\n+# the actual connect+OAuth runs on the dedicated `mcp-event-loop` thread via\n+# run_coroutine_threadsafe. asyncio copies the *calling context* into the\n+# scheduled coroutine, so a ContextVar propagates across that boundary while a\n+# threading.local would not — see #35927. Default True (interactive allowed).\n+_oauth_interactive_enabled: \"contextvars.ContextVar[bool]\" = contextvars.ContextVar(\n+    \"_oauth_interactive_enabled\", default=True\n+)\n \n \n # Skip tokens accepted at the paste prompt — exit OAuth without auth.","@@ -137,12 +148,30 @@ def _find_free_port() -> int:\n \n def _is_interactive() -> bool:\n     \"\"\"Return True if we can reasonably expect to interact with a user.\"\"\"\n+    if not _oauth_interactive_enabled.get():\n+        return False\n     try:\n         return sys.stdin.isatty()\n     except (AttributeError, ValueError):\n         return False\n \n \n+@contextmanager\n+def suppress_interactive_oauth():\n+    \"\"\"Disable stdin-based OAuth prompts for the current execution context.\n+\n+    Uses a ContextVar so the suppression propagates from a background-discovery\n+    thread onto the coroutine scheduled (via run_coroutine_threadsafe) on the\n+    dedicated MCP event-loop thread — where the OAuth callback actually runs\n+    (#35927). A threading.local would not cross that thread boundary.\n+    \"\"\"\n+    token = [redacted:secret-assignment]\n+    try:\n+        yield\n+    finally:\n+        _oauth_interactive_enabled.reset(token)\n+\n+\n def _can_open_browser() -> bool:\n     \"\"\"Return True if opening a browser is likely to work.\"\"\"\n     # Explicit SSH session → no local display"]},{"file":"tui_gateway/entry.py","hunks":["@@ -293,8 +293,11 @@ def main():\n     if _has_mcp_servers:\n         def _discover_mcp_background() -> None:\n             try:\n-                from tools.mcp_tool import discover_mcp_tools\n-                discover_mcp_tools()\n+                from hermes_cli.mcp_startup import (\n+                    _discover_mcp_tools_without_interactive_oauth,\n+                )\n+\n+                _discover_mcp_tools_without_interactive_oauth()\n             except Exception:\n                 logger.warning(\n                     \"Background MCP tool discovery failed\", exc_info=True\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}