# NousResearch/hermes-agent · MCP OAuth probe timeout too short or ignored

## Open now

- 2 issues, 1 PR open on GitHub.
- #49759: \[Feature\]: something like "OAuth listener timeout too short for identity verification flows"
- #49760: \[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.
- PR #140167: fix(mcp): hermes mcp add --auth oauth waits for the browser sign-in like mcp login

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.

## TL;DR

- 13 member(s): 2 open issue(s), 4 closed issue(s), 1 open PR(s), 1 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #59219. Verify it addresses your case.
- **Recommended:** read PR #59219 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-59219`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.16`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #49759: \[Feature\]: something like "OAuth listener timeout too short for identity verification flows"
- #49760: \[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.

## Closed / duplicate issues

- #24097: \[Bug\]: MCP OAuth login times out at 40s; per-server connect_timeout silently ignored
- #36725: \[Setup\]: hermes mcp login asana
- #50485: MCP OAuth flow times out at 40s — _probe_single_server default connect_timeout too short for interactive OAuth
- #54692: mcp reauth times out before a human can finish the interactive OAuth login

## Prior & competing PRs

- **#24606: fix(mcp): read connect_timeout from server config in _probe_single_server; guard float() coercion**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24656`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#24656: fix(mcp): read connect_timeout from server config in _probe_single_server; default to 300s for OAuth**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24184`, `#24097`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#54696: fix(mcp): allow generous timeout for interactive OAuth reauth probe**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `f26ae4f6830d0ced54ab61cd1ef03b35fadb3f91`, `#54692`
- **#59219: fix(mcp): honor per-server connect_timeout in CLI/GUI probes (salvage #34276, #56699, #54494)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/mcp_config.py`

    ```diff
    @@ -247,7 +247,7 @@ def _resolve_mcp_server_config(config: dict) -> dict:
     
     
     def _probe_single_server(
    -    name: str, config: dict, connect_timeout: float = 30, *, details: Optional[dict] = None
    +    name: str, config: dict, connect_timeout: Optional[float] = None, *, details: Optional[dict] = None
     ) -> List[Tuple[str, str]]:
         """Temporarily connect to one MCP server, list its tools, disconnect.
     
    ```

    ```diff
    @@ -271,9 +271,14 @@ def _probe_single_server(
         )
     
         config = _resolve_mcp_server_config(config)
    +    if connect_timeout is None:
    +        raw_timeout = config.get("connect_timeout", 30)
    +        try:
    +            connect_timeout = max(1.0, float(raw_timeout))
    +        except (TypeError, ValueError):
    +            connect_timeout = 30.0
     
         _ensure_mcp_loop()
    -
         tools_found: List[Tuple[str, str]] = []
     
         async def _probe():
    ```

    ```diff
    @@ -392,6 +397,7 @@ def cmd_mcp_add(args):
         auth_type = getattr(args, "auth", None)
         preset_name = getattr(args, "preset", None)
         raw_env = getattr(args, "env", None)
    +    raw_connect_timeout = getattr(args, "connect_timeout", None)
     
         server_config: Dict[str, Any] = {}
         try:
    ```

    ```diff
    @@ -437,6 +443,8 @@ def cmd_mcp_add(args):
                 server_config["args"] = cmd_args
             if explicit_env:
                 server_config["env"] = explicit_env
    +    if raw_connect_timeout is not None:
    +        server_config["connect_timeout"] = raw_connect_timeout
     
         issues = validate_mcp_server_entry(name, server_config)
         if issues:
    ```

    ```diff
    @@ -776,8 +784,21 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool:
         _info(f"Starting OAuth flow for '{name}'...")
     
         # Probe triggers the OAuth flow (browser redirect + callback capture).
    +    # Honor the server's configured connect_timeout so a human has enough
    +    # time to complete the browser sign-in; the 30s default is too tight for
    +    # an interactive OAuth round-trip. Floor at 315s — the OAuth callback
    +    # window (300s in mcp_oauth) plus headroom — matching the GUI re-auth
    +    # path in web_server.py so CLI and dashboard behave identically.
         try:
    -        tools = _probe_single_server(name, server_config)
    +        _login_connect_timeout = server_config.get("connect_timeout")
    +        try:
    +            _login_connect_timeout = float(_login_connect_timeout)
    +        except (TypeError, ValueError):
    +            _login_connect_timeout = 0.0
    +        _login_connect_timeout = max(_login_connect_timeout, 315.0)
    +        tools = _probe_single_server(
    +            name, server_config, connect_timeout=_login_connect_timeout
    +        )
             # A clean probe is NOT proof of authentication. Some MCP servers
             # (notably Google's official Drive server) serve initialize +
             # tools/list WITHOUT auth, so the probe lists tools even when the
    ```
  - `hermes_cli/subcommands/mcp.py`

    ```diff
    @@ -60,6 +60,11 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:
         )
         mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
         mcp_add_p.add_argument("--preset", help="Known MCP preset name")
    +    mcp_add_p.add_argument(
    +        "--connect-timeout",
    +        type=float,
    +        help="Timeout in seconds for initial connection and tool discovery",
    +    )
         mcp_add_p.add_argument(
             "--env",
             nargs="*",
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -9152,8 +9152,15 @@ def _run():
                     # The default 30s connect timeout would kill the flow while the
                     # user is still on the consent screen — give the browser
                     # round-trip the full callback window (300s in mcp_oauth) plus
    -                # headroom so the connect wrapper can't pre-empt it.
    -                tools = _probe_single_server(name, cfg, connect_timeout=315)
    +                # headroom so the connect wrapper can't pre-empt it. Honor a
    +                # larger configured connect_timeout when the user set one.
    +                try:
    +                    _cfg_timeout = float(cfg.get("connect_timeout", 0))
    +                except (TypeError, ValueError):
    +                    _cfg_timeout = 0.0
    +                tools = _probe_single_server(
    +                    name, cfg, connect_timeout=max(_cfg_timeout, 315)
    +                )
                 except Exception:
                     storage.restore(backup)
                     raise
    ```
  - `scripts/release.py`

    ```diff
    @@ -1204,6 +1204,8 @@
         "[redacted:email]": "r266-tech",
         "[redacted:email]": "r266-tech",  # PR #55780 salvage (dead-target not_found blast radius)
         "[redacted:email]": "BlackishGreen33",
    +    "[redacted:email]": "sberan",  # PR #54494 salvage (--connect-timeout flag on hermes mcp add)
    +    "[redacted:email]": "labsobsidian",  # PR #56699 salvage (MCP OAuth login connect_timeout floor)
         "[redacted:email]": "SaulJWu",
         "[redacted:email]": "hcshen0111",
         "[redacted:email]": "Cygra",
    ```
  - `tests/hermes_cli/test_mcp_add_command_dest.py`

    ```diff
    @@ -41,6 +41,7 @@ def _build_parser():
         mcp_add.add_argument("name")
         mcp_add.add_argument("--url")
         mcp_add.add_argument("--command", dest="mcp_command")
    +    mcp_add.add_argument("--connect-timeout", type=float)
         mcp_add.add_argument("--args", nargs=argparse.REMAINDER, default=[])
     
         return parser
    ```

    ```diff
    @@ -87,6 +88,25 @@ def test_bare_mcp_add_does_not_clobber_command(self):
             assert args.mcp_command is None
             assert args.url is None
     
    +    def test_connect_timeout_flag_sets_probe_timeout(self):
    +        """`--connect-timeout` exposes the per-server discovery timeout."""
    +        parser = _build_parser()
    +        args = parser.parse_args(
    +            [
    +                "mcp",
    +                "add",
    +                "slow",
    +                "--url",
    +                "https://example.com/mcp",
    +                "--connect-timeout",
    +                "180",
    +            ]
    +        )
    +
    +        assert args.command == "mcp"
    +        assert args.mcp_action == "add"
    +        assert args.connect_timeout == 180
    +
         def test_args_passthrough_keeps_nested_option_flags(self):
             """`--args` must keep command flags like Docker MCP's --profile."""
             parser = _build_parser()
    ```
  - `tests/hermes_cli/test_mcp_config.py`

    ```diff
    @@ -445,6 +445,44 @@ def mock_probe(name, config, **kw):
             assert "Connected" in out
             assert "Tools discovered: 2" in out
     
    +    def test_probe_uses_configured_connect_timeout(self, monkeypatch):
    +        """OAuth-capable probes must not hard-code a short 30s timeout."""
    +        import asyncio
    +        from hermes_cli import mcp_config
    +        import tools.mcp_tool as mcp_tool
    +
    +        captured = {}
    +
    +        class FakeServer:
    +            _tools = []
    +
    +            async def shutdown(self):
    +                captured["shutdown"] = True
    +
    +        async def fake_connect(name, config):
    +            return FakeServer()
    +
    +        def fake_run_on_mcp_loop(coro, timeout):
    +            captured["outer_timeout"] = timeout
    +            return asyncio.run(coro)
    +
    +        async def fake_wait_for(awaitable, timeout):
    +            captured["inner_timeout"] = timeout
    +            return await awaitable
    +
    +        monkeypatch.setattr(mcp_tool, "_ensure_mcp_loop", lambda: None)
    +        monkeypatch.setattr(mcp_tool, "_stop_mcp_loop_if_idle", lambda: None)
    +        monkeypatch.setattr(mcp_tool, "_connect_server", fake_connect)
    +        monkeypatch.setattr(mcp_tool, "_run_on_mcp_loop", fake_run_on_mcp_loop)
    +        monkeypatch.setattr(mcp_config.asyncio, "wait_for", fake_wait_for)
    +
    +        assert mcp_config._probe_single_server(
    +            "supabase", {"connect_timeout": 300}
    +        ) == []
    +        assert captured["inner_timeout"] == 300.0
    +        assert captured["outer_timeout"] == 310.0
    +        assert captured["shutdown"] is True
    +
     
     # ---------------------------------------------------------------------------
     # Tests: env var interpolation
    ```

    ```diff
    @@ -830,7 +868,9 @@ def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):
             # Probe returns tools even though auth never completed.
             monkeypatch.setattr(
                 "hermes_cli.mcp_config._probe_single_server",
    -            lambda name, cfg: [("search_files", "d"), ("read_file_content", "d")],
    +            lambda name, cfg, connect_timeout=30: [
    +                ("search_files", "d"), ("read_file_content", "d"),
    +            ],
             )
             # No token file is created → _oauth_tokens_present() returns False.
             from hermes_cli.mcp_config import cmd_mcp_login
    ```

    ```diff
    @@ -852,7 +892,10 @@ def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):
             # cmd_mcp_login wipes tokens before probing, then the real OAuth flow
             # writes a fresh token during the probe. Simulate that: the mocked
             # probe drops a token file, mirroring a successful authorization.
    -        def mock_probe(name, cfg):
    +        seen = {}
    +
    +        def mock_probe(name, cfg, connect_timeout=30):
    +            seen["connect_timeout"] = connect_timeout
                 token_dir.mkdir(exist_ok=True)
                 (token_dir / "realserver.json").write_text('{"access_token": "x"}')
                 return [("a", "d"), ("b", "d"), ("c", "d")]
    ```

    ```diff
    @@ -868,6 +911,9 @@ def mock_probe(name, cfg):
     
             assert "Authenticated — 3 tool(s) available" in out
             assert "no OAuth token" not in out
    +        # The login path must grant a human enough time to finish the browser
    +        # OAuth round-trip — far longer than the 30s probe default.
    +        assert seen["connect_timeout"] >= 180
     
     
     # ---------------------------------------------------------------------------
    ```
- **#24184: fix(mcp): honor per-server connect_timeout + raise default for OAuth probes**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`
- **#34276: fix(cli): honor MCP probe connect timeout**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `f26ae4f68`, `#59219`, `#24097`
- **#140167: fix(mcp): hermes mcp add --auth oauth waits for the browser sign-in like mcp login**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#140167`
  - files: `hermes_cli/mcp_config.py`, `tests/hermes_cli/test_mcp_login_window.py`

## Fix status

- Merged to main in PR #59219 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-06`.
- Subsystem: `hermes_cli`
