{"repo":"NousResearch/hermes-agent","public_id":"24097","display_label":"MCP OAuth probe timeout too short or ignored","summary":"Multiple bugs and fixes center on the MCP OAuth login probe using a hardcoded 40-second timeout that is too short for interactive flows (e.g., identity verification), and failing to read the per-server connect_timeout from config. Related work includes OAuth 2.1 PKCE implementation and a headless stdin fallback.","subsystem":"hermes_cli","counts":{"members":13,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":4,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59219},"resolution":{"state":"partially_resolved","by":"pr-59219"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T02:10:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15794871794871795},"fix_edges":[{"issue_number":24097,"pr_number":59219,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":24097,"title":"[Bug]: MCP OAuth login times out at 40s; per-server connect_timeout silently ignored","url":null,"state":"closed"},{"kind":"issue","number":36725,"title":"[Setup]: hermes mcp login asana","url":null,"state":"closed"},{"kind":"issue","number":49759,"title":"[Feature]: something like \"OAuth listener timeout too short for identity verification flows\"","url":null,"state":"open"},{"kind":"issue","number":49760,"title":"[Bug]:  hermes mcp add robinhood --url https://agent.robinhood.com/mcp/trading --auth oauth — complete login, then start the selfie/ID verification step; the terminal shows timeout before verification completes.","url":null,"state":"open"},{"kind":"issue","number":50485,"title":"MCP OAuth flow times out at 40s — _probe_single_server default connect_timeout too short for interactive OAuth","url":null,"state":"closed"},{"kind":"issue","number":54692,"title":"mcp reauth times out before a human can finish the interactive OAuth login","url":null,"state":"closed"},{"kind":"pr","number":24184,"title":"fix(mcp): honor per-server connect_timeout + raise default for OAuth probes","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":24606,"title":"fix(mcp): read connect_timeout from server config in _probe_single_server; guard float() coercion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24656","number":24656}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24656,"title":"fix(mcp): read connect_timeout from server config in _probe_single_server; default to 300s for OAuth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24184","number":24184},{"kind":"pr","value":"#24097","number":24097}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":34276,"title":"fix(cli): honor MCP probe connect timeout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f26ae4f68","number":0},{"kind":"pr","value":"#59219","number":59219},{"kind":"pr","value":"#24097","number":24097}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54696,"title":"fix(mcp): allow generous timeout for interactive OAuth reauth probe","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f26ae4f6830d0ced54ab61cd1ef03b35fadb3f91","number":0},{"kind":"pr","value":"#54692","number":54692}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":59219,"title":"fix(mcp): honor per-server connect_timeout in CLI/GUI probes (salvage #34276, #56699, #54494)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/mcp_config.py","hermes_cli/subcommands/mcp.py","hermes_cli/web_server.py","scripts/release.py","tests/hermes_cli/test_mcp_add_command_dest.py","tests/hermes_cli/test_mcp_config.py"],"fix_hunks":[{"file":"hermes_cli/mcp_config.py","hunks":["@@ -247,7 +247,7 @@ def _resolve_mcp_server_config(config: dict) -> dict:\n \n \n def _probe_single_server(\n-    name: str, config: dict, connect_timeout: float = 30, *, details: Optional[dict] = None\n+    name: str, config: dict, connect_timeout: Optional[float] = None, *, details: Optional[dict] = None\n ) -> List[Tuple[str, str]]:\n     \"\"\"Temporarily connect to one MCP server, list its tools, disconnect.\n ","@@ -271,9 +271,14 @@ def _probe_single_server(\n     )\n \n     config = _resolve_mcp_server_config(config)\n+    if connect_timeout is None:\n+        raw_timeout = config.get(\"connect_timeout\", 30)\n+        try:\n+            connect_timeout = max(1.0, float(raw_timeout))\n+        except (TypeError, ValueError):\n+            connect_timeout = 30.0\n \n     _ensure_mcp_loop()\n-\n     tools_found: List[Tuple[str, str]] = []\n \n     async def _probe():","@@ -392,6 +397,7 @@ def cmd_mcp_add(args):\n     auth_type = getattr(args, \"auth\", None)\n     preset_name = getattr(args, \"preset\", None)\n     raw_env = getattr(args, \"env\", None)\n+    raw_connect_timeout = getattr(args, \"connect_timeout\", None)\n \n     server_config: Dict[str, Any] = {}\n     try:","@@ -437,6 +443,8 @@ def cmd_mcp_add(args):\n             server_config[\"args\"] = cmd_args\n         if explicit_env:\n             server_config[\"env\"] = explicit_env\n+    if raw_connect_timeout is not None:\n+        server_config[\"connect_timeout\"] = raw_connect_timeout\n \n     issues = validate_mcp_server_entry(name, server_config)\n     if issues:","@@ -776,8 +784,21 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool:\n     _info(f\"Starting OAuth flow for '{name}'...\")\n \n     # Probe triggers the OAuth flow (browser redirect + callback capture).\n+    # Honor the server's configured connect_timeout so a human has enough\n+    # time to complete the browser sign-in; the 30s default is too tight for\n+    # an interactive OAuth round-trip. Floor at 315s — the OAuth callback\n+    # window (300s in mcp_oauth) plus headroom — matching the GUI re-auth\n+    # path in web_server.py so CLI and dashboard behave identically.\n     try:\n-        tools = _probe_single_server(name, server_config)\n+        _login_connect_timeout = server_config.get(\"connect_timeout\")\n+        try:\n+            _login_connect_timeout = float(_login_connect_timeout)\n+        except (TypeError, ValueError):\n+            _login_connect_timeout = 0.0\n+        _login_connect_timeout = max(_login_connect_timeout, 315.0)\n+        tools = _probe_single_server(\n+            name, server_config, connect_timeout=_login_connect_timeout\n+        )\n         # A clean probe is NOT proof of authentication. Some MCP servers\n         # (notably Google's official Drive server) serve initialize +\n         # tools/list WITHOUT auth, so the probe lists tools even when the"]},{"file":"hermes_cli/subcommands/mcp.py","hunks":["@@ -60,6 +60,11 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:\n     )\n     mcp_add_p.add_argument(\"--auth\", choices=[\"oauth\", \"header\"], help=\"Auth method\")\n     mcp_add_p.add_argument(\"--preset\", help=\"Known MCP preset name\")\n+    mcp_add_p.add_argument(\n+        \"--connect-timeout\",\n+        type=float,\n+        help=\"Timeout in seconds for initial connection and tool discovery\",\n+    )\n     mcp_add_p.add_argument(\n         \"--env\",\n         nargs=\"*\","]},{"file":"hermes_cli/web_server.py","hunks":["@@ -9152,8 +9152,15 @@ def _run():\n                 # The default 30s connect timeout would kill the flow while the\n                 # user is still on the consent screen — give the browser\n                 # round-trip the full callback window (300s in mcp_oauth) plus\n-                # headroom so the connect wrapper can't pre-empt it.\n-                tools = _probe_single_server(name, cfg, connect_timeout=315)\n+                # headroom so the connect wrapper can't pre-empt it. Honor a\n+                # larger configured connect_timeout when the user set one.\n+                try:\n+                    _cfg_timeout = float(cfg.get(\"connect_timeout\", 0))\n+                except (TypeError, ValueError):\n+                    _cfg_timeout = 0.0\n+                tools = _probe_single_server(\n+                    name, cfg, connect_timeout=max(_cfg_timeout, 315)\n+                )\n             except Exception:\n                 storage.restore(backup)\n                 raise"]},{"file":"scripts/release.py","hunks":["@@ -1204,6 +1204,8 @@\n     \"[redacted:email]\": \"r266-tech\",\n     \"[redacted:email]\": \"r266-tech\",  # PR #55780 salvage (dead-target not_found blast radius)\n     \"[redacted:email]\": \"BlackishGreen33\",\n+    \"[redacted:email]\": \"sberan\",  # PR #54494 salvage (--connect-timeout flag on hermes mcp add)\n+    \"[redacted:email]\": \"labsobsidian\",  # PR #56699 salvage (MCP OAuth login connect_timeout floor)\n     \"[redacted:email]\": \"SaulJWu\",\n     \"[redacted:email]\": \"hcshen0111\",\n     \"[redacted:email]\": \"Cygra\","]},{"file":"tests/hermes_cli/test_mcp_add_command_dest.py","hunks":["@@ -41,6 +41,7 @@ def _build_parser():\n     mcp_add.add_argument(\"name\")\n     mcp_add.add_argument(\"--url\")\n     mcp_add.add_argument(\"--command\", dest=\"mcp_command\")\n+    mcp_add.add_argument(\"--connect-timeout\", type=float)\n     mcp_add.add_argument(\"--args\", nargs=argparse.REMAINDER, default=[])\n \n     return parser","@@ -87,6 +88,25 @@ def test_bare_mcp_add_does_not_clobber_command(self):\n         assert args.mcp_command is None\n         assert args.url is None\n \n+    def test_connect_timeout_flag_sets_probe_timeout(self):\n+        \"\"\"`--connect-timeout` exposes the per-server discovery timeout.\"\"\"\n+        parser = _build_parser()\n+        args = parser.parse_args(\n+            [\n+                \"mcp\",\n+                \"add\",\n+                \"slow\",\n+                \"--url\",\n+                \"https://example.com/mcp\",\n+                \"--connect-timeout\",\n+                \"180\",\n+            ]\n+        )\n+\n+        assert args.command == \"mcp\"\n+        assert args.mcp_action == \"add\"\n+        assert args.connect_timeout == 180\n+\n     def test_args_passthrough_keeps_nested_option_flags(self):\n         \"\"\"`--args` must keep command flags like Docker MCP's --profile.\"\"\"\n         parser = _build_parser()"]},{"file":"tests/hermes_cli/test_mcp_config.py","hunks":["@@ -445,6 +445,44 @@ def mock_probe(name, config, **kw):\n         assert \"Connected\" in out\n         assert \"Tools discovered: 2\" in out\n \n+    def test_probe_uses_configured_connect_timeout(self, monkeypatch):\n+        \"\"\"OAuth-capable probes must not hard-code a short 30s timeout.\"\"\"\n+        import asyncio\n+        from hermes_cli import mcp_config\n+        import tools.mcp_tool as mcp_tool\n+\n+        captured = {}\n+\n+        class FakeServer:\n+            _tools = []\n+\n+            async def shutdown(self):\n+                captured[\"shutdown\"] = True\n+\n+        async def fake_connect(name, config):\n+            return FakeServer()\n+\n+        def fake_run_on_mcp_loop(coro, timeout):\n+            captured[\"outer_timeout\"] = timeout\n+            return asyncio.run(coro)\n+\n+        async def fake_wait_for(awaitable, timeout):\n+            captured[\"inner_timeout\"] = timeout\n+            return await awaitable\n+\n+        monkeypatch.setattr(mcp_tool, \"_ensure_mcp_loop\", lambda: None)\n+        monkeypatch.setattr(mcp_tool, \"_stop_mcp_loop_if_idle\", lambda: None)\n+        monkeypatch.setattr(mcp_tool, \"_connect_server\", fake_connect)\n+        monkeypatch.setattr(mcp_tool, \"_run_on_mcp_loop\", fake_run_on_mcp_loop)\n+        monkeypatch.setattr(mcp_config.asyncio, \"wait_for\", fake_wait_for)\n+\n+        assert mcp_config._probe_single_server(\n+            \"supabase\", {\"connect_timeout\": 300}\n+        ) == []\n+        assert captured[\"inner_timeout\"] == 300.0\n+        assert captured[\"outer_timeout\"] == 310.0\n+        assert captured[\"shutdown\"] is True\n+\n \n # ---------------------------------------------------------------------------\n # Tests: env var interpolation","@@ -830,7 +868,9 @@ def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):\n         # Probe returns tools even though auth never completed.\n         monkeypatch.setattr(\n             \"hermes_cli.mcp_config._probe_single_server\",\n-            lambda name, cfg: [(\"search_files\", \"d\"), (\"read_file_content\", \"d\")],\n+            lambda name, cfg, connect_timeout=30: [\n+                (\"search_files\", \"d\"), (\"read_file_content\", \"d\"),\n+            ],\n         )\n         # No token file is created → _oauth_tokens_present() returns False.\n         from hermes_cli.mcp_config import cmd_mcp_login","@@ -852,7 +892,10 @@ def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):\n         # cmd_mcp_login wipes tokens before probing, then the real OAuth flow\n         # writes a fresh token during the probe. Simulate that: the mocked\n         # probe drops a token file, mirroring a successful authorization.\n-        def mock_probe(name, cfg):\n+        seen = {}\n+\n+        def mock_probe(name, cfg, connect_timeout=30):\n+            seen[\"connect_timeout\"] = connect_timeout\n             token_dir.mkdir(exist_ok=True)\n             (token_dir / \"realserver.json\").write_text('{\"access_token\": \"x\"}')\n             return [(\"a\", \"d\"), (\"b\", \"d\"), (\"c\", \"d\")]","@@ -868,6 +911,9 @@ def mock_probe(name, cfg):\n \n         assert \"Authenticated — 3 tool(s) available\" in out\n         assert \"no OAuth token\" not in out\n+        # The login path must grant a human enough time to finish the browser\n+        # OAuth round-trip — far longer than the 30s probe default.\n+        assert seen[\"connect_timeout\"] >= 180\n \n \n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":140167,"title":"fix(mcp): hermes mcp add --auth oauth waits for the browser sign-in like mcp login","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#140167","number":140167}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}