{"repo":"NousResearch/hermes-agent","public_id":"5344","display_label":"MCP OAuth callback port conflicts and global state issues","summary":"These issues and fixes address port collision errors, shared global state pollution, and address-reuse problems in MCP OAuth callback handling. Additional concerns include fail-fast behavior for headless/non-interactive environments with stale or expired tokens.","subsystem":"agent","counts":{"members":18,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":6,"merged_prs":3,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58154},"resolution":{"state":"partially_resolved","by":"pr-58154,pr-65622,pr-65664"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T14:11:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1168627450980392},"fix_edges":[{"issue_number":5344,"pr_number":65622,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34260,"pr_number":65664,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44588,"pr_number":65622,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44590,"pr_number":65622,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":57836,"pr_number":58154,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5344,"title":"MCP OAuth: port collision on concurrent auth + redirect URI mismatch on restart","url":null,"state":"closed"},{"kind":"issue","number":34260,"title":"MCP OAuth callback: module-level port global causes port collisions and structural weaknesses vs upstream","url":null,"state":"closed"},{"kind":"issue","number":44588,"title":"OAuth redirect_handler/callback_handler use shared globals, causing cross-server state pollution","url":null,"state":"closed"},{"kind":"issue","number":44590,"title":"OAuth: ephemeral HTTPServer per flow causes OSError Address already in use","url":null,"state":"closed"},{"kind":"issue","number":56673,"title":"Headless MCP OAuth reconnect hangs on a no-refresh-token credential and surfaces no needs_reauth state (distinct from #18165's transport recovery)","url":null,"state":"closed"},{"kind":"issue","number":57836,"title":"[Bug]: Headless MCP OAuth blocks gateway startup with stale cached tokens","url":null,"state":"closed"},{"kind":"pr","number":5345,"title":"fix(mcp-oauth): per-server callback ports + reuse registered redirect URI","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65622","number":65622},{"kind":"pr","value":"#44607","number":44607},{"kind":"pr","value":"#44685","number":44685},{"kind":"pr","value":"#44872","number":44872},{"kind":"pr","value":"#44872","number":44872}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44607,"title":"Bind MCP OAuth handlers per provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65622","number":65622},{"kind":"pr","value":"#44872","number":44872},{"kind":"pr","value":"#5345","number":5345}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44611,"title":"Reuse MCP OAuth callback listener addresses","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65622","number":65622}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44680,"title":"fix(mcp): set SO_REUSEADDR on OAuth callback server to prevent EADDRINUSE","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44611","number":44611}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":44685,"title":"fix(mcp): replace global _oauth_port with per-provider closures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65622","number":65622},{"kind":"pr","value":"#44872","number":44872},{"kind":"pr","value":"#5345","number":5345}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44872,"title":"fix(mcp): use per-provider closures and allow_reuse_address for OAuth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"13e19a909","number":0},{"kind":"commit","value":"f4c7caa70","number":0},{"kind":"pr","value":"#65622","number":65622},{"kind":"pr","value":"#22161","number":22161}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54325,"title":"fix(mcp-oauth): eliminate _oauth_port global, defer server binding, restore fail-fast guards","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34260","number":34260},{"kind":"pr","value":"#34280","number":34280},{"kind":"pr","value":"#44607","number":44607},{"kind":"pr","value":"#54325","number":54325},{"kind":"pr","value":"#44588","number":44588},{"kind":"pr","value":"#44590","number":44590},{"kind":"pr","value":"#50786","number":50786}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56690,"title":"fix(mcp): fail fast on headless expired OAuth tokens","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58000,"title":"fix(mcp): fail fast at OAuth redirect/callback boundary when non-interactive (#57836)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"755194ffe","number":0},{"kind":"commit","value":"0c8441c88","number":0},{"kind":"pr","value":"#58154","number":58154},{"kind":"pr","value":"#57836","number":57836},{"kind":"pr","value":"#57836","number":57836}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58154,"title":"fix(mcp): fail fast at OAuth redirect/callback boundary when non-interactive (#57836)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_oauth.py","tools/mcp_oauth.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -262,6 +262,7 @@ def _run(self, coro):\n     def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n         monkeypatch.setattr(mco, \"_oauth_port\", 49200)\n+        monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.setenv(\"SSH_CLIENT\", \"1.2.3.4 1234 22\")\n         monkeypatch.delenv(\"SSH_TTY\", raising=False)\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: False)","@@ -276,6 +277,7 @@ def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys):\n     def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n         monkeypatch.setattr(mco, \"_oauth_port\", 49201)\n+        monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.delenv(\"SSH_CLIENT\", raising=False)\n         monkeypatch.setenv(\"SSH_TTY\", \"/dev/pts/1\")\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: False)","@@ -289,6 +291,7 @@ def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys):\n     def test_no_ssh_hint_on_local_session(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n         monkeypatch.setattr(mco, \"_oauth_port\", 49202)\n+        monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.delenv(\"SSH_CLIENT\", raising=False)\n         monkeypatch.delenv(\"SSH_TTY\", raising=False)\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: True)","@@ -302,6 +305,7 @@ def test_no_ssh_hint_on_local_session(self, monkeypatch, capsys):\n     def test_no_ssh_hint_when_port_not_set(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n         monkeypatch.setattr(mco, \"_oauth_port\", None)\n+        monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.setenv(\"SSH_CLIENT\", \"1.2.3.4 1234 22\")\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: False)\n ","@@ -527,12 +531,19 @@ def _discovery():\n class TestWaitForCallbackNoBlocking:\n     \"\"\"_wait_for_callback() must never call input() — it raises instead.\"\"\"\n \n-    def test_raises_on_timeout_instead_of_input(self):\n-        \"\"\"When no auth code arrives, raises OAuthNonInteractiveError.\"\"\"\n+    def test_raises_on_timeout_instead_of_input(self, monkeypatch):\n+        \"\"\"Interactive session: when no auth code arrives, raises on timeout.\n+\n+        Marked interactive so the fail-fast non-interactive guard (#57836)\n+        does not short-circuit — this test exercises the timeout path.\n+        \"\"\"\n         import tools.mcp_oauth as mod\n         import asyncio\n \n         mod._oauth_port = _find_free_port()\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: True)\n+        # EOF on the paste reader so only the HTTP-listener timeout drives it.\n+        monkeypatch.setattr(\"sys.stdin\", MagicMock(readline=lambda: \"\"))\n \n         async def instant_sleep(_seconds):\n             pass","@@ -583,6 +594,116 @@ def test_noninteractive_with_cached_tokens_no_warning(self, tmp_path, monkeypatc\n         assert \"no cached tokens found\" not in caplog.text.lower()\n \n \n+class TestNonInteractiveFailFastAtCallbackBoundary:\n+    \"\"\"#57836: a cached-but-unusable token (expired/revoked, refresh rejected)\n+    makes the MCP SDK fall through to the authorization-code flow even though\n+    build_oauth_auth's token-file guard passed. In a non-interactive context\n+    (systemd gateway, cron, background discovery) that flow must fail fast at\n+    the redirect/callback boundary — never launch a browser flow or bind a\n+    callback listener, and never block for the full timeout — so gateway\n+    startup is not gated on an unusable optional MCP server, and retries do not\n+    collide on the callback port ('Address already in use').\n+    \"\"\"\n+\n+    def test_wait_for_callback_rejects_before_binding_when_noninteractive(self, monkeypatch):\n+        \"\"\"No listener bound and no poll loop entered when non-interactive.\"\"\"\n+        import tools.mcp_oauth as mod\n+        import asyncio\n+\n+        mod._oauth_port = _find_free_port()\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n+\n+        # Binding the callback listener or entering the poll loop is the bug.\n+        fake_server = MagicMock(side_effect=AssertionError(\"must not bind callback listener\"))\n+        monkeypatch.setattr(mod, \"HTTPServer\", fake_server)\n+\n+        async def no_sleep(_seconds):\n+            raise AssertionError(\"must not wait for the callback timeout\")\n+        monkeypatch.setattr(mod.asyncio, \"sleep\", no_sleep)\n+\n+        with pytest.raises(OAuthNonInteractiveError, match=\"interactive session\"):\n+            asyncio.run(mod._wait_for_callback())\n+        fake_server.assert_not_called()\n+\n+    def test_wait_for_callback_fail_fast_holds_even_with_cached_token_file(self, tmp_path, monkeypatch):\n+        \"\"\"Guard does not depend on token-file existence.\n+\n+        A stale token file on disk passes build_oauth_auth's guard, so the\n+        callback boundary is the only place that can reject the flow.\n+        \"\"\"\n+        import tools.mcp_oauth as mod\n+        import asyncio\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        d = tmp_path / \"mcp-tokens\"\n+        d.mkdir(parents=True)\n+        (d / \"example.json\").write_text(\n+            json.dumps({\"access_token\": \"stale\", \"token_type\": \"Bearer\"})\n+        )\n+\n+        mod._oauth_port = _find_free_port()\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n+        monkeypatch.setattr(\n+            mod, \"HTTPServer\", MagicMock(side_effect=AssertionError(\"must not bind\"))\n+        )\n+\n+        with pytest.raises(OAuthNonInteractiveError):\n+            asyncio.run(mod._wait_for_callback())\n+\n+    def test_redirect_handler_rejects_and_does_not_open_browser(self, monkeypatch, capsys):\n+        \"\"\"Non-interactive redirect must not print an auth URL or open a browser.\"\"\"\n+        import tools.mcp_oauth as mod\n+        import asyncio\n+\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n+        monkeypatch.setattr(\n+            \"webbrowser.open\", MagicMock(side_effect=AssertionError(\"must not open browser\"))\n+        )\n+\n+        with pytest.raises(OAuthNonInteractiveError, match=\"browser authorization\"):\n+            asyncio.run(mod._redirect_handler(\"https://idp.example.com/authorize?x=1\"))\n+\n+        err = capsys.readouterr().err\n+        assert \"https://idp.example.com/authorize\" not in err\n+\n+    def test_boundary_errors_point_at_hermes_mcp_login(self, monkeypatch):\n+        \"\"\"Both boundaries emit an actionable next step.\"\"\"\n+        import tools.mcp_oauth as mod\n+        import asyncio\n+\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n+        with pytest.raises(OAuthNonInteractiveError, match=\"hermes mcp login\"):\n+            asyncio.run(mod._redirect_handler(\"https://idp.example.com/authorize\"))\n+\n+        mod._oauth_port = _find_free_port()\n+        with pytest.raises(OAuthNonInteractiveError, match=\"hermes mcp login\"):\n+            asyncio.run(mod._wait_for_callback())\n+\n+    def test_guard_does_not_fire_on_interactive_redirect(self, monkeypatch, capsys):\n+        \"\"\"Positive control: the fail-fast guard is scoped to the auth-code path.\n+\n+        #57836 regression coverage asks that valid/refreshable OAuth keeps\n+        working non-interactively — a good token never reaches these handlers,\n+        so the guard must be inert once a real flow is in progress. Assert the\n+        interactive path still prints the URL and does not raise, proving the\n+        guard does not over-fire and swallow legitimate authorization.\n+        \"\"\"\n+        import tools.mcp_oauth as mod\n+        import asyncio\n+\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: True)\n+        # Local (non-SSH) interactive session with no browser available, so the\n+        # handler falls through to the manual-URL print without opening a tab.\n+        monkeypatch.delenv(\"SSH_CLIENT\", raising=False)\n+        monkeypatch.delenv(\"SSH_TTY\", raising=False)\n+        monkeypatch.setattr(mod, \"_can_open_browser\", lambda: False)\n+\n+        asyncio.run(mod._redirect_handler(\"https://idp.example.com/authorize?x=9\"))\n+\n+        err = capsys.readouterr().err\n+        assert \"https://idp.example.com/authorize?x=9\" in err\n+\n+\n # ---------------------------------------------------------------------------\n # Extracted helper tests (Task 3 of MCP OAuth consolidation)\n # ---------------------------------------------------------------------------"]},{"file":"tools/mcp_oauth.py","hunks":["@@ -167,6 +167,21 @@ def _is_interactive() -> bool:\n         return False\n \n \n+def _raise_if_non_interactive(lead: str) -> None:\n+    \"\"\"Raise ``OAuthNonInteractiveError`` unless an interactive session exists.\n+\n+    ``lead`` is the boundary-specific first sentence; this helper appends the\n+    shared, actionable ``hermes mcp login`` next-step so the guidance wording\n+    lives in one place across every non-interactive OAuth boundary (#57836).\n+    \"\"\"\n+    if not _is_interactive():\n+        raise OAuthNonInteractiveError(\n+            f\"{lead} \"\n+            \"Run `hermes mcp login <server>` interactively to (re)authorize, \"\n+            \"then restart or reload the gateway.\"\n+        )\n+\n+\n @contextmanager\n def force_interactive_oauth():\n     \"\"\"Treat the current execution context as interactive despite no TTY.","@@ -529,6 +544,21 @@ async def _redirect_handler(authorization_url: str) -> None:\n     Opens the browser automatically when possible; always prints the URL\n     as a fallback for headless/SSH/gateway environments.\n     \"\"\"\n+    # Fail fast at the authorization boundary in non-interactive contexts\n+    # (systemd gateway, cron, background MCP discovery). A cached-but-unusable\n+    # token (expired/revoked, refresh rejected) makes the SDK fall through to\n+    # the authorization-code flow even though build_oauth_auth's token-file\n+    # guard passed. Without this check we would print a URL and launch a\n+    # browser flow no operator can complete, then block in _wait_for_callback\n+    # for the full timeout. Raise before launching so gateway adapters start\n+    # promptly and the caller can skip this server with an actionable warning.\n+    # This intentionally re-checks interactivity here rather than trusting the\n+    # token-file existence guard alone. See #57836.\n+    _raise_if_non_interactive(\n+        \"MCP OAuth requires browser authorization but no interactive \"\n+        \"session is available (non-interactive/background context).\"\n+    )\n+\n     msg = (\n         f\"\\n  MCP OAuth: authorization required.\\n\"\n         f\"  Open this URL in your browser:\\n\\n\"","@@ -600,6 +630,22 @@ async def _wait_for_callback() -> tuple[str, str | None]:\n             \"before _wait_for_oauth_callback\"\n         )\n \n+    # Reject before binding the callback listener in non-interactive contexts.\n+    # Reaching here means the SDK entered the authorization-code flow (a valid\n+    # or refreshable token would never call the callback handler), so a cached\n+    # token file is present but unusable. Binding the listener here would block\n+    # for the full 300s timeout and — on the next connection retry — collide\n+    # with the still-bound/TIME_WAIT port, surfacing as\n+    # ``OSError: [Errno 98] Address already in use``. Failing fast keeps\n+    # gateway startup independent of an unusable optional MCP server. This\n+    # guard holds \"regardless of whether a token file exists\" — the point the\n+    # build_oauth_auth token-file guard cannot cover. See #57836.\n+    _raise_if_non_interactive(\n+        \"OAuth callback requires an interactive session but none is \"\n+        \"available (non-interactive/background context); skipping browser \"\n+        \"authorization without binding a callback listener.\"\n+    )\n+\n     # The callback server is already running (started in build_oauth_auth).\n     # We just need to poll for the result.\n     handler_cls, result = _make_callback_handler()\n"]}]},{"kind":"pr","number":65622,"title":"fix(mcp): close OAuth callback port races — closures, reuse_address, TOCTOU (salvage of #44872 + #22161)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_mcp_oauth.py","tools/mcp_oauth.py","tools/mcp_oauth_manager.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -346,6 +346,8 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"Code-suphub\",\n+    \"[redacted:email]\": \"Code-suphub\",\n     \"[redacted:email]\": \"brendandebeasi\",\n     \"[redacted:email]\": \"dorokuma\",\n     \"[redacted:email]\": \"liuwei666888\","]},{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -21,7 +21,7 @@\n     _is_interactive,\n     _wait_for_callback,\n     _make_callback_handler,\n-    _redirect_handler,\n+    _make_redirect_handler,\n     _paste_callback_reader,\n )\n ","@@ -254,20 +254,20 @@ def test_can_open_browser_true_with_display(self, monkeypatch):\n \n \n class TestRedirectHandlerSshHint:\n-    \"\"\"_redirect_handler must print an SSH tunnel hint on remote sessions.\"\"\"\n+    \"\"\"_make_redirect_handler must print an SSH tunnel hint on remote sessions.\"\"\"\n \n     def _run(self, coro):\n         return asyncio.get_event_loop().run_until_complete(coro)\n \n     def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n-        monkeypatch.setattr(mco, \"_oauth_port\", 49200)\n         monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.setenv(\"SSH_CLIENT\", \"1.2.3.4 1234 22\")\n         monkeypatch.delenv(\"SSH_TTY\", raising=False)\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: False)\n \n-        self._run(_redirect_handler(\"https://example.com/auth?foo=bar\"))\n+        handler = _make_redirect_handler(49200)\n+        self._run(handler(\"https://example.com/auth?foo=bar\"))\n \n         err = capsys.readouterr().err\n         assert \"49200\" in err","@@ -276,13 +276,13 @@ def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys):\n \n     def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n-        monkeypatch.setattr(mco, \"_oauth_port\", 49201)\n         monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.delenv(\"SSH_CLIENT\", raising=False)\n         monkeypatch.setenv(\"SSH_TTY\", \"/dev/pts/1\")\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: False)\n \n-        self._run(_redirect_handler(\"https://example.com/auth\"))\n+        handler = _make_redirect_handler(49201)\n+        self._run(handler(\"https://example.com/auth\"))\n \n         err = capsys.readouterr().err\n         assert \"49201\" in err","@@ -290,26 +290,26 @@ def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys):\n \n     def test_no_ssh_hint_on_local_session(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n-        monkeypatch.setattr(mco, \"_oauth_port\", 49202)\n         monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.delenv(\"SSH_CLIENT\", raising=False)\n         monkeypatch.delenv(\"SSH_TTY\", raising=False)\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: True)\n         monkeypatch.setattr(\"webbrowser.open\", lambda url, **kw: True)\n \n-        self._run(_redirect_handler(\"https://example.com/auth\"))\n+        handler = _make_redirect_handler(49202)\n+        self._run(handler(\"https://example.com/auth\"))\n \n         err = capsys.readouterr().err\n         assert \"ssh -N -L\" not in err\n \n-    def test_no_ssh_hint_when_port_not_set(self, monkeypatch, capsys):\n+    def test_no_ssh_hint_when_port_is_zero(self, monkeypatch, capsys):\n         import tools.mcp_oauth as mco\n-        monkeypatch.setattr(mco, \"_oauth_port\", None)\n         monkeypatch.setattr(mco, \"_is_interactive\", lambda: True)\n         monkeypatch.setenv(\"SSH_CLIENT\", \"1.2.3.4 1234 22\")\n         monkeypatch.setattr(mco, \"_can_open_browser\", lambda: False)\n \n-        self._run(_redirect_handler(\"https://example.com/auth\"))\n+        handler = _make_redirect_handler(0)\n+        self._run(handler(\"https://example.com/auth\"))\n \n         err = capsys.readouterr().err\n         assert \"ssh -N -L\" not in err","@@ -423,6 +423,103 @@ def test_port_stored_globally(self, tmp_path, monkeypatch):\n         assert 1024 <= mod._oauth_port <= 65535\n \n \n+# ---------------------------------------------------------------------------\n+# TOCTOU port reservation (#22161)\n+# ---------------------------------------------------------------------------\n+\n+class TestCallbackPortReservation:\n+    \"\"\"The socket picked at selection time stays bound until callback bind.\n+\n+    _find_free_port() closed its probe socket before HTTPServer re-bound the\n+    port, leaving a race window where another process could steal it\n+    (#22161). _reserve_callback_port() keeps the bound socket parked in\n+    _reserved_sockets until _wait_for_callback adopts it.\n+    \"\"\"\n+\n+    def test_reserved_port_cannot_be_stolen(self):\n+        import socket as sock\n+        import tools.mcp_oauth as mod\n+\n+        port = mod._reserve_callback_port()\n+        try:\n+            # The reservation holds the bind — a competing bind must fail.\n+            thief = sock.socket(sock.AF_INET, sock.SOCK_STREAM)\n+            with pytest.raises(OSError):\n+                thief.bind((\"127.0.0.1\", port))\n+            thief.close()\n+        finally:\n+            reserved = mod._reserved_sockets.pop(port, None)\n+            if reserved is not None:\n+                reserved.close()\n+\n+    def test_configure_callback_port_reserves_ephemeral(self):\n+        import tools.mcp_oauth as mod\n+\n+        cfg: dict = {}\n+        port = mod._configure_callback_port(cfg)\n+        try:\n+            assert cfg[\"_resolved_port\"] == port\n+            assert port in mod._reserved_sockets\n+        finally:\n+            reserved = mod._reserved_sockets.pop(port, None)\n+            if reserved is not None:\n+                reserved.close()\n+\n+    def test_pinned_port_is_not_reserved(self):\n+        import tools.mcp_oauth as mod\n+\n+        cfg: dict = {\"redirect_port\": 49399}\n+        port = mod._configure_callback_port(cfg)\n+        assert port == 49399\n+        assert 49399 not in mod._reserved_sockets\n+\n+    def test_reservation_pool_is_bounded(self):\n+        import tools.mcp_oauth as mod\n+\n+        ports = [mod._reserve_callback_port() for _ in range(mod._MAX_RESERVED_SOCKETS + 3)]\n+        try:\n+            assert len(mod._reserved_sockets) <= mod._MAX_RESERVED_SOCKETS\n+            # newest reservations survive\n+            assert ports[-1] in mod._reserved_sockets\n+        finally:\n+            for p in list(mod._reserved_sockets):\n+                mod._reserved_sockets.pop(p).close()\n+\n+    def test_wait_for_callback_adopts_reserved_socket(self, monkeypatch):\n+        \"\"\"E2E: reserve → _wait_for_callback binds the SAME socket and the\n+        callback round-trips through it.\"\"\"\n+        import asyncio\n+        import threading\n+        import urllib.request\n+        import tools.mcp_oauth as mod\n+\n+        cfg: dict = {}\n+        port = mod._configure_callback_port(cfg)\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n+        # Bypass the non-interactive guard — this test drives the flow directly.\n+        monkeypatch.setattr(mod, \"_raise_if_non_interactive\", lambda lead: None)\n+\n+        async def drive():\n+            task = asyncio.create_task(mod._wait_for_callback())\n+            await asyncio.sleep(0.2)  # let the server adopt the socket\n+\n+            def hit():\n+                urllib.request.urlopen(\n+                    f\"http://127.0.0.1:{port}/callback?code=abc123&state=xyz\",\n+                    timeout=5,\n+                )\n+\n+            t = threading.Thread(target=hit, daemon=True)\n+            t.start()\n+            return await asyncio.wait_for(task, timeout=10)\n+\n+        code, state = asyncio.run(drive())\n+        assert code == \"abc123\"\n+        assert state == \"xyz\"\n+        # Reservation was consumed by adoption.\n+        assert port not in mod._reserved_sockets\n+\n+\n # ---------------------------------------------------------------------------\n # remove_oauth_tokens\n # ---------------------------------------------------------------------------","@@ -661,7 +758,7 @@ def test_redirect_handler_rejects_and_does_not_open_browser(self, monkeypatch, c\n         )\n \n         with pytest.raises(OAuthNonInteractiveError, match=\"browser authorization\"):\n-            asyncio.run(mod._redirect_handler(\"https://idp.example.com/authorize?x=1\"))\n+            asyncio.run(mod._make_redirect_handler(49300)(\"https://idp.example.com/authorize?x=1\"))\n \n         err = capsys.readouterr().err\n         assert \"https://idp.example.com/authorize\" not in err","@@ -673,7 +770,7 @@ def test_boundary_errors_point_at_hermes_mcp_login(self, monkeypatch):\n \n         monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n         with pytest.raises(OAuthNonInteractiveError, match=\"hermes mcp login\"):\n-            asyncio.run(mod._redirect_handler(\"https://idp.example.com/authorize\"))\n+            asyncio.run(mod._make_redirect_handler(49301)(\"https://idp.example.com/authorize\"))\n \n         mod._oauth_port = _find_free_port()\n         with pytest.raises(OAuthNonInteractiveError, match=\"hermes mcp login\"):","@@ -698,7 +795,7 @@ def test_guard_does_not_fire_on_interactive_redirect(self, monkeypatch, capsys):\n         monkeypatch.delenv(\"SSH_TTY\", raising=False)\n         monkeypatch.setattr(mod, \"_can_open_browser\", lambda: False)\n \n-        asyncio.run(mod._redirect_handler(\"https://idp.example.com/authorize?x=9\"))\n+        asyncio.run(mod._make_redirect_handler(49302)(\"https://idp.example.com/authorize?x=9\"))\n \n         err = capsys.readouterr().err\n         assert \"https://idp.example.com/authorize?x=9\" in err"]},{"file":"tools/mcp_oauth.py","hunks":["@@ -155,6 +155,43 @@ def _find_free_port() -> int:\n         return s.getsockname()[1]\n \n \n+# Bound-but-not-listening sockets reserved for pending OAuth callback flows,\n+# keyed by port. Holding the socket from port-selection time until\n+# _wait_for_callback adopts it closes the TOCTOU window where another process\n+# could grab the port between _find_free_port() closing its probe socket and\n+# HTTPServer binding minutes later (#22161). Bounded FIFO so repeated\n+# build_oauth_auth calls (reconnect loops) cannot leak fds.\n+_reserved_sockets: \"dict[int, socket.socket]\" = {}\n+_MAX_RESERVED_SOCKETS = 8\n+\n+\n+def _reserve_callback_port() -> int:\n+    \"\"\"Pick an ephemeral callback port and keep its socket bound.\n+\n+    Returns the port. The bound (not yet listening) socket is parked in\n+    ``_reserved_sockets`` so no other process can bind the port before\n+    ``_wait_for_callback`` adopts it. Adoption (or ``server_close``) owns\n+    the socket's lifetime from there.\n+    \"\"\"\n+    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n+    try:\n+        s.bind((\"127.0.0.1\", 0))\n+    except OSError:\n+        s.close()\n+        raise\n+    port = s.getsockname()[1]\n+    # Evict oldest reservations past the cap (dict preserves insertion order).\n+    while len(_reserved_sockets) >= _MAX_RESERVED_SOCKETS:\n+        _, stale = next(iter(_reserved_sockets.items()))\n+        _reserved_sockets.pop(next(iter(_reserved_sockets)), None)\n+        try:\n+            stale.close()\n+        except OSError:\n+            pass\n+    _reserved_sockets[port] = s\n+    return port\n+\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():","@@ -538,70 +575,79 @@ def log_message(self, fmt: str, *args: Any) -> None:\n # ---------------------------------------------------------------------------\n \n \n-async def _redirect_handler(authorization_url: str) -> None:\n-    \"\"\"Show the authorization URL to the user.\n+def _make_redirect_handler(port: int):\n+    \"\"\"Return a redirect handler closure that closes over the given port.\n \n-    Opens the browser automatically when possible; always prints the URL\n-    as a fallback for headless/SSH/gateway environments.\n+    Using a closure instead of reading the module-level ``_oauth_port`` avoids\n+    cross-server state pollution when multiple MCP servers run OAuth\n+    concurrently (fixes #44588).\n     \"\"\"\n-    # Fail fast at the authorization boundary in non-interactive contexts\n-    # (systemd gateway, cron, background MCP discovery). A cached-but-unusable\n-    # token (expired/revoked, refresh rejected) makes the SDK fall through to\n-    # the authorization-code flow even though build_oauth_auth's token-file\n-    # guard passed. Without this check we would print a URL and launch a\n-    # browser flow no operator can complete, then block in _wait_for_callback\n-    # for the full timeout. Raise before launching so gateway adapters start\n-    # promptly and the caller can skip this server with an actionable warning.\n-    # This intentionally re-checks interactivity here rather than trusting the\n-    # token-file existence guard alone. See #57836.\n-    _raise_if_non_interactive(\n-        \"MCP OAuth requires browser authorization but no interactive \"\n-        \"session is available (non-interactive/background context).\"\n-    )\n+    async def _redirect_handler(authorization_url: str) -> None:\n+        \"\"\"Show the authorization URL to the user.\n \n-    msg = (\n-        f\"\\n  MCP OAuth: authorization required.\\n\"\n-        f\"  Open this URL in your browser:\\n\\n\"\n-        f\"    {authorization_url}\\n\"\n-    )\n-    print(msg, file=sys.stderr)\n-\n-    # On a remote SSH session the OAuth provider redirects to\n-    # http://127.0.0.1:<port>/callback, which reaches the callback server on\n-    # the *remote* machine — not the user's local machine where the browser\n-    # opened.  Two ways out: paste the redirect URL back (default fallback,\n-    # offered by _wait_for_callback on interactive TTYs), or set up an SSH\n-    # port forward so the redirect tunnels through.\n-    if _oauth_port and (os.getenv(\"SSH_CLIENT\") or os.getenv(\"SSH_TTY\")):\n-        print(\n-            f\"  Remote session detected. After you authorize, the provider redirects to\\n\"\n-            f\"    http://127.0.0.1:{_oauth_port}/callback\\n\"\n-            f\"  which only the listener on THIS machine can receive. Two options:\\n\"\n-            f\"\\n\"\n-            f\"    1. Easiest — when your browser shows a connection error after\\n\"\n-            f\"       authorizing, copy the full URL from the address bar and paste\\n\"\n-            f\"       it at the prompt below. The pasted ``code=...&state=...`` is\\n\"\n-            f\"       enough to complete the flow.\\n\"\n-            f\"\\n\"\n-            f\"    2. Or forward the port first in a separate terminal:\\n\"\n-            f\"         ssh -N -L {_oauth_port}:127.0.0.1:{_oauth_port} <user>@<this-host>\\n\"\n-            f\"       then open the URL above and let it redirect normally.\\n\"\n-            f\"\\n\"\n-            f\"  See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\\n\",\n-            file=sys.stderr,\n+        Opens the browser automatically when possible; always prints the URL\n+        as a fallback for headless/SSH/gateway environments.\n+        \"\"\"\n+        # Fail fast at the authorization boundary in non-interactive contexts\n+        # (systemd gateway, cron, background MCP discovery). A cached-but-unusable\n+        # token (expired/revoked, refresh rejected) makes the SDK fall through to\n+        # the authorization-code flow even though build_oauth_auth's token-file\n+        # guard passed. Without this check we would print a URL and launch a\n+        # browser flow no operator can complete, then block in _wait_for_callback\n+        # for the full timeout. Raise before launching so gateway adapters start\n+        # promptly and the caller can skip this server with an actionable warning.\n+        # This intentionally re-checks interactivity here rather than trusting the\n+        # token-file existence guard alone. See #57836.\n+        _raise_if_non_interactive(\n+            \"MCP OAuth requires browser authorization but no interactive \"\n+            \"session is available (non-interactive/background context).\"\n         )\n \n-    if _can_open_browser():\n-        try:\n-            opened = webbrowser.open(authorization_url)\n-            if opened:\n-                print(\"  (Browser opened automatically.)\\n\", file=sys.stderr)\n-            else:\n+        msg = (\n+            f\"\\n  MCP OAuth: authorization required.\\n\"\n+            f\"  Open this URL in your browser:\\n\\n\"\n+            f\"    {authorization_url}\\n\"\n+        )\n+        print(msg, file=sys.stderr)\n+\n+        # On a remote SSH session the OAuth provider redirects to\n+        # http://127.0.0.1:<port>/callback, which reaches the callback server on\n+        # the *remote* machine — not the user's local machine where the browser\n+        # opened.  Two ways out: paste the redirect URL back (default fallback,\n+        # offered by _wait_for_callback on interactive TTYs), or set up an SSH\n+        # port forward so the redirect tunnels through.\n+        if port and (os.getenv(\"SSH_CLIENT\") or os.getenv(\"SSH_TTY\")):\n+            print(\n+                f\"  Remote session detected. After you authorize, the provider redirects to\\n\"\n+                f\"    http://127.0.0.1:{port}/callback\\n\"\n+                f\"  which only the listener on THIS machine can receive. Two options:\\n\"\n+                f\"\\n\"\n+                f\"    1. Easiest — when your browser shows a connection error after\\n\"\n+                f\"       authorizing, copy the full URL from the address bar and paste\\n\"\n+                f\"       it at the prompt below. The pasted ``code=...&state=...`` is\\n\"\n+                f\"       enough to complete the flow.\\n\"\n+                f\"\\n\"\n+                f\"    2. Or forward the port first in a separate terminal:\\n\"\n+                f\"         ssh -N -L {port}:127.0.0.1:{port} <user>@<this-host>\\n\"\n+                f\"       then open the URL above and let it redirect normally.\\n\"\n+                f\"\\n\"\n+                f\"  See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\\n\",\n+                file=sys.stderr,\n+            )\n+\n+        if _can_open_browser():\n+            try:\n+                opened = webbrowser.open(authorization_url)\n+                if opened:\n+                    print(\"  (Browser opened automatically.)\\n\", file=sys.stderr)\n+                else:\n+                    print(\"  (Could not open browser — please open the URL manually.)\\n\", file=sys.stderr)\n+            except Exception:\n                 print(\"  (Could not open browser — please open the URL manually.)\\n\", file=sys.stderr)\n-        except Exception:\n-            print(\"  (Could not open browser — please open the URL manually.)\\n\", file=sys.stderr)\n-    else:\n-        print(\"  (Headless environment detected — open the URL manually.)\\n\", file=sys.stderr)\n+        else:\n+            print(\"  (Headless environment detected — open the URL manually.)\\n\", file=sys.stderr)\n+\n+    return _redirect_handler\n \n \n async def _wait_for_callback() -> tuple[str, str | None]:","@@ -650,9 +696,29 @@ async def _wait_for_callback() -> tuple[str, str | None]:\n     # We just need to poll for the result.\n     handler_cls, result = _make_callback_handler()\n \n-    # Start a temporary server on the known port\n+    # Start a temporary server on the known port, adopting the socket\n+    # reserved at port-selection time when one exists. Holding the bound\n+    # socket from _reserve_callback_port() until here closes the TOCTOU\n+    # window where another process could steal the port between selection\n+    # and bind (#22161). allow_reuse_address is set BEFORE binding (setting\n+    # it after the constructor has already bound is a no-op) so a lingering\n+    # TIME_WAIT socket from a previous flow cannot block the next one\n+    # (#44590).\n     try:\n-        server = HTTPServer((\"127.0.0.1\", _oauth_port), handler_cls)\n+        server = HTTPServer(\n+            (\"127.0.0.1\", _oauth_port), handler_cls, bind_and_activate=False\n+        )\n+        reserved = _reserved_sockets.pop(_oauth_port, None)\n+        if reserved is not None:\n+            # Adopt the reserved (already bound) socket and start listening.\n+            server.socket.close()\n+            server.socket = reserved\n+            server.server_address = reserved.getsockname()\n+            server.server_activate()\n+        else:\n+            server.allow_reuse_address = True\n+            server.server_bind()\n+            server.server_activate()\n     except OSError:\n         # Port already in use — the server from build_oauth_auth is running.\n         # Fall back to polling the server started by build_oauth_auth.","@@ -827,7 +893,10 @@ def _configure_callback_port(cfg: dict) -> int:\n     \"\"\"\n     global _oauth_port\n     requested = int(cfg.get(\"redirect_port\", 0))\n-    port = _find_free_port() if requested == 0 else requested\n+    # Ephemeral selection reserves the bound socket until _wait_for_callback\n+    # adopts it, closing the select→bind TOCTOU race (#22161). An explicit\n+    # user-pinned port is used as-is.\n+    port = _reserve_callback_port() if requested == 0 else requested\n     cfg[\"_resolved_port\"] = port\n     _oauth_port = port  # legacy consumer: _wait_for_callback reads this\n     return port","@@ -938,11 +1007,15 @@ def build_oauth_auth(\n     client_metadata = _build_client_metadata(cfg)\n     _maybe_preregister_client(storage, cfg, client_metadata)\n \n+    # Use closure factories to avoid global state pollution (#44588).\n+    resolved_port = cfg.get(\"_resolved_port\", _oauth_port)\n+    redirect_handler = _make_redirect_handler(resolved_port)\n+\n     return OAuthClientProvider(\n         server_url=server_url,\n         client_metadata=client_metadata,\n         storage=storage,\n-        redirect_handler=_redirect_handler,\n+        redirect_handler=redirect_handler,\n         callback_handler=_wait_for_callback,\n         timeout=float(cfg.get(\"timeout\", 300)),\n     )"]},{"file":"tools/mcp_oauth_manager.py","hunks":["@@ -522,7 +522,7 @@ def _build_provider(\n             _configure_callback_port,\n             _is_interactive,\n             _maybe_preregister_client,\n-            _redirect_handler,\n+            _make_redirect_handler,\n             _wait_for_callback,\n         )\n ","@@ -545,13 +545,16 @@ def _build_provider(\n         client_metadata = _build_client_metadata(cfg)\n         _maybe_preregister_client(storage, cfg, client_metadata)\n \n+        resolved_port = cfg.get(\"_resolved_port\", 0)\n+        redirect_handler = _make_redirect_handler(resolved_port)\n+\n         return _HERMES_PROVIDER_CLS(\n             server_name=server_name,\n             preregistered=bool(cfg.get(\"client_id\")),\n             server_url=entry.server_url,\n             client_metadata=client_metadata,\n             storage=storage,\n-            redirect_handler=_redirect_handler,\n+            redirect_handler=redirect_handler,\n             callback_handler=_wait_for_callback,\n             timeout=float(cfg.get(\"timeout\", 300)),\n         )\n"]}]},{"kind":"pr","number":65664,"title":"fix(mcp): per-flow OAuth callback waiters + clear port-in-use error (salvage of #50786, direction of #34280)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_mcp_oauth.py","tools/mcp_oauth.py","tools/mcp_oauth_manager.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -353,6 +353,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"doxe0x\",\n     \"[redacted:email]\": \"Code-suphub\",\n     \"[redacted:email]\": \"Code-suphub\",\n     \"[redacted:email]\": \"shuangxinniao\","]},{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -561,6 +561,54 @@ def hit():\n         # Reservation was consumed by adoption.\n         assert port not in mod._reserved_sockets\n \n+    def test_concurrent_flows_keep_their_own_callback_ports(self, monkeypatch):\n+        \"\"\"#34260: flow A's waiter listens on A's port even after flow B\n+        overwrites the legacy module-level global.\n+\n+        This is the callback-side sibling of the #44588 redirect-handler fix:\n+        without a per-flow waiter, A's callback wait would bind B's port and\n+        A's redirect (pointing at A's port) would never be received.\n+        \"\"\"\n+        import asyncio\n+        import threading\n+        import urllib.request\n+        import tools.mcp_oauth as mod\n+\n+        monkeypatch.setattr(mod, \"_is_interactive\", lambda: False)\n+        monkeypatch.setattr(mod, \"_raise_if_non_interactive\", lambda lead: None)\n+\n+        cfg_a: dict = {}\n+        port_a = mod._configure_callback_port(cfg_a)\n+        waiter_a = mod._make_callback_waiter(port_a)\n+        # Flow B configures afterwards — overwrites mod._oauth_port.\n+        cfg_b: dict = {}\n+        port_b = mod._configure_callback_port(cfg_b)\n+        assert mod._oauth_port == port_b != port_a\n+\n+        async def drive():\n+            task = asyncio.create_task(waiter_a())\n+            await asyncio.sleep(0.2)\n+\n+            def hit():\n+                # The redirect goes to flow A's port — where A's waiter\n+                # must be listening despite the clobbered global.\n+                urllib.request.urlopen(\n+                    f\"http://127.0.0.1:{port_a}/callback?code=flowA&state=sA\",\n+                    timeout=5,\n+                )\n+\n+            threading.Thread(target=hit, daemon=True).start()\n+            return await asyncio.wait_for(task, timeout=10)\n+\n+        try:\n+            code, state = asyncio.run(drive())\n+        finally:\n+            leftover = mod._reserved_sockets.pop(port_b, None)\n+            if leftover is not None:\n+                leftover.close()\n+        assert code == \"flowA\"\n+        assert state == \"sA\"\n+\n \n # ---------------------------------------------------------------------------\n # remove_oauth_tokens","@@ -1470,3 +1518,22 @@ def test_poison_noop_when_no_client_file(self, tmp_path, monkeypatch):\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n         storage = HermesTokenStorage(\"srv\")\n         assert storage.poison_client_registration() is False\n+\n+\n+def test_wait_for_callback_port_in_use_reports_clear_error(monkeypatch):\n+    \"\"\"A busy loopback callback port surfaces a clear 'already in use' error,\n+    not a misleading 'timed out'. Guards the stale-comment fix where the branch\n+    also wrongly claimed build_oauth_auth had started a server to poll.\"\"\"\n+    import tools.mcp_oauth as mo\n+\n+    monkeypatch.setattr(mo, \"_is_interactive\", lambda: True)\n+    with patch.object(mo, \"_oauth_port\", 54321), patch.object(\n+        mo, \"HTTPServer\", side_effect=OSError(\"address already in use\")\n+    ):\n+        with pytest.raises(mo.OAuthNonInteractiveError) as excinfo:\n+            asyncio.run(mo._wait_for_callback())\n+\n+    msg = str(excinfo.value)\n+    assert \"54321\" in msg\n+    assert \"already in use\" in msg\n+    assert \"timed out\" not in msg"]},{"file":"tools/mcp_oauth.py","hunks":["@@ -668,21 +668,14 @@ async def _redirect_handler(authorization_url: str) -> None:\n \n \n async def _wait_for_callback() -> tuple[str, str | None]:\n-    \"\"\"Wait for the OAuth callback to arrive on the local callback server.\n+    \"\"\"Wait for the OAuth callback on the legacy module-level port.\n \n-    Uses the module-level ``_oauth_port`` which is set by ``build_oauth_auth``\n-    before this is ever called.  Polls for the result without blocking the\n-    event loop.\n-\n-    On an interactive TTY, races the HTTP listener against a stdin paste\n-    fallback so users without an SSH tunnel can copy the redirect URL (or\n-    just the ``code=...&state=...`` query string) from a browser on another\n-    machine and paste it back. The HTTP listener wins when the redirect\n-    reaches it first; the paste fallback wins when it doesn't.\n+    Kept for backwards compatibility with callers that never went through\n+    :func:`build_oauth_auth`'s per-flow wiring. New code paths receive a\n+    per-flow waiter from :func:`_make_callback_waiter` so concurrent OAuth\n+    flows cannot cross ports (#34260).\n \n     Raises:\n-        OAuthNonInteractiveError: If the callback times out (no user present\n-            to complete the browser auth).\n         RuntimeError: If ``_oauth_port`` has not been set, which would indicate\n             that ``build_oauth_auth`` was skipped — the asserting form below\n             was a silent bug when running Python with ``-O``/``-OO``.","@@ -692,102 +685,129 @@ async def _wait_for_callback() -> tuple[str, str | None]:\n             \"OAuth callback port not set — build_oauth_auth must be called \"\n             \"before _wait_for_oauth_callback\"\n         )\n+    return await _make_callback_waiter(_oauth_port)()\n \n-    # Reject before binding the callback listener in non-interactive contexts.\n-    # Reaching here means the SDK entered the authorization-code flow (a valid\n-    # or refreshable token would never call the callback handler), so a cached\n-    # token file is present but unusable. Binding the listener here would block\n-    # for the full 300s timeout and — on the next connection retry — collide\n-    # with the still-bound/TIME_WAIT port, surfacing as\n-    # ``OSError: [Errno 98] Address already in use``. Failing fast keeps\n-    # gateway startup independent of an unusable optional MCP server. This\n-    # guard holds \"regardless of whether a token file exists\" — the point the\n-    # build_oauth_auth token-file guard cannot cover. See #57836.\n-    _raise_if_non_interactive(\n-        \"OAuth callback requires an interactive session but none is \"\n-        \"available (non-interactive/background context); skipping browser \"\n-        \"authorization without binding a callback listener.\"\n-    )\n \n-    # The callback server is already running (started in build_oauth_auth).\n-    # We just need to poll for the result.\n-    handler_cls, result = _make_callback_handler()\n-\n-    # Start a temporary server on the known port, adopting the socket\n-    # reserved at port-selection time when one exists. Holding the bound\n-    # socket from _reserve_callback_port() until here closes the TOCTOU\n-    # window where another process could steal the port between selection\n-    # and bind (#22161). allow_reuse_address is set BEFORE binding (setting\n-    # it after the constructor has already bound is a no-op) so a lingering\n-    # TIME_WAIT socket from a previous flow cannot block the next one\n-    # (#44590).\n-    try:\n-        server = HTTPServer(\n-            (\"127.0.0.1\", _oauth_port), handler_cls, bind_and_activate=False\n-        )\n-        reserved = _reserved_sockets.pop(_oauth_port, None)\n-        if reserved is not None:\n-            # Adopt the reserved (already bound) socket and start listening.\n-            server.socket.close()\n-            server.socket = reserved\n-            server.server_address = reserved.getsockname()\n-            server.server_activate()\n-        else:\n-            server.allow_reuse_address = True\n-            server.server_bind()\n-            server.server_activate()\n-    except OSError:\n-        # Port already in use — the server from build_oauth_auth is running.\n-        # Fall back to polling the server started by build_oauth_auth.\n-        raise OAuthNonInteractiveError(\n-            \"OAuth callback timed out — could not bind callback port. \"\n-            \"Complete the authorization in a browser first, then retry.\"\n-        )\n+def _make_callback_waiter(port: int):\n+    \"\"\"Return a callback waiter bound to a single OAuth flow's port.\n \n-    server_thread = threading.Thread(target=server.handle_request, daemon=True)\n-    server_thread.start()\n+    Closing over the port (instead of reading the module-level\n+    ``_oauth_port``) keeps concurrent OAuth flows isolated: flow A's waiter\n+    listens on flow A's port even when flow B's ``_configure_callback_port``\n+    overwrites the legacy global afterwards (#34260, the callback-side\n+    sibling of the #44588 redirect-handler fix).\n \n-    # Optional paste-fallback thread: only on interactive TTYs. Reads one\n-    # line from stdin and writes the parsed code/state into the shared\n-    # result dict. The HTTP listener and this thread race for the result;\n-    # whichever fills it first wins.\n-    paste_thread: threading.Thread | None = None\n-    if _is_interactive():\n-        print(\n-            \"\\n  Or paste the redirect URL here (or the ``?code=...&state=...`` \"\n-            \"portion) and press Enter. Type ``skip`` + Enter to continue \"\n-            \"without this server:\",\n-            file=sys.stderr,\n-            flush=True,\n-        )\n-        paste_thread = threading.Thread(\n-            target=_paste_callback_reader, args=(result,), daemon=True\n-        )\n-        paste_thread.start()\n+    The waiter polls for the redirect without blocking the event loop. On an\n+    interactive TTY it races the HTTP listener against a stdin paste fallback\n+    so users without an SSH tunnel can paste the redirect URL (or just the\n+    ``code=...&state=...`` query string) from a browser on another machine.\n \n-    timeout = 300.0\n-    poll_interval = 0.5\n-    elapsed = 0.0\n-    try:\n-        while elapsed < timeout:\n-            if result[\"auth_code\"] is not None or result[\"error\"] is not None:\n-                break\n-            await asyncio.sleep(poll_interval)\n-            elapsed += poll_interval\n-    finally:\n-        server.server_close()\n+    Raises (when awaited):\n+        OAuthNonInteractiveError: If the callback times out (no user present\n+            to complete the browser auth), or in non-interactive contexts.\n+    \"\"\"\n \n-    if result[\"error\"] == _USER_SKIPPED_SENTINEL:\n-        raise OAuthNonInteractiveError(\"user_skipped\")\n-    if result[\"error\"]:\n-        raise RuntimeError(f\"OAuth authorization failed: {result['error']}\")\n-    if result[\"auth_code\"] is None:\n-        raise OAuthNonInteractiveError(\n-            \"OAuth callback timed out — no authorization code received. \"\n-            \"Ensure you completed the browser authorization flow.\"\n+    async def _wait() -> tuple[str, str | None]:\n+        # Reject before binding the callback listener in non-interactive\n+        # contexts. Reaching here means the SDK entered the authorization-code\n+        # flow (a valid or refreshable token would never call the callback\n+        # handler), so a cached token file is present but unusable. Binding the\n+        # listener here would block for the full 300s timeout and — on the next\n+        # connection retry — collide with the still-bound/TIME_WAIT port,\n+        # surfacing as ``OSError: [Errno 98] Address already in use``. Failing\n+        # fast keeps gateway startup independent of an unusable optional MCP\n+        # server. This guard holds \"regardless of whether a token file exists\"\n+        # — the point the build_oauth_auth token-file guard cannot cover.\n+        # See #57836.\n+        _raise_if_non_interactive(\n+            \"OAuth callback requires an interactive session but none is \"\n+            \"available (non-interactive/background context); skipping browser \"\n+            \"authorization without binding a callback listener.\"\n         )\n \n-    return result[\"auth_code\"], result[\"state\"]\n+        handler_cls, result = _make_callback_handler()\n+\n+        # Start a temporary server on this flow's port, adopting the socket\n+        # reserved at port-selection time when one exists. Holding the bound\n+        # socket from _reserve_callback_port() until here closes the TOCTOU\n+        # window where another process could steal the port between selection\n+        # and bind (#22161). allow_reuse_address is set BEFORE binding (setting\n+        # it after the constructor has already bound is a no-op) so a lingering\n+        # TIME_WAIT socket from a previous flow cannot block the next one\n+        # (#44590).\n+        try:\n+            server = HTTPServer(\n+                (\"127.0.0.1\", port), handler_cls, bind_and_activate=False\n+            )\n+            reserved = _reserved_sockets.pop(port, None)\n+            if reserved is not None:\n+                # Adopt the reserved (already bound) socket and start listening.\n+                server.socket.close()\n+                server.socket = reserved\n+                server.server_address = reserved.getsockname()\n+                server.server_activate()\n+            else:\n+                server.allow_reuse_address = True\n+                server.server_bind()\n+                server.server_activate()\n+        except OSError as exc:\n+            # The loopback callback port is genuinely in use: a concurrent OAuth\n+            # flow, a leftover listener, or a fixed `oauth.redirect_port` that\n+            # collided. build_oauth_auth does not start its own callback server,\n+            # so there is nothing to poll here; surface a clear, actionable error\n+            # instead of a misleading \"timed out\".\n+            raise OAuthNonInteractiveError(\n+                f\"OAuth callback port {port} is already in use ({exc}). \"\n+                \"Close any other in-progress login, or set a free `oauth.redirect_port` \"\n+                \"in the server config, then retry.\"\n+            ) from exc\n+\n+        server_thread = threading.Thread(target=server.handle_request, daemon=True)\n+        server_thread.start()\n+\n+        # Optional paste-fallback thread: only on interactive TTYs. Reads one\n+        # line from stdin and writes the parsed code/state into the shared\n+        # result dict. The HTTP listener and this thread race for the result;\n+        # whichever fills it first wins.\n+        paste_thread: threading.Thread | None = None\n+        if _is_interactive():\n+            print(\n+                \"\\n  Or paste the redirect URL here (or the ``?code=...&state=...`` \"\n+                \"portion) and press Enter. Type ``skip`` + Enter to continue \"\n+                \"without this server:\",\n+                file=sys.stderr,\n+                flush=True,\n+            )\n+            paste_thread = threading.Thread(\n+                target=_paste_callback_reader, args=(result,), daemon=True\n+            )\n+            paste_thread.start()\n+\n+        timeout = 300.0\n+        poll_interval = 0.5\n+        elapsed = 0.0\n+        try:\n+            while elapsed < timeout:\n+                if result[\"auth_code\"] is not None or result[\"error\"] is not None:\n+                    break\n+                await asyncio.sleep(poll_interval)\n+                elapsed += poll_interval\n+        finally:\n+            server.server_close()\n+\n+        if result[\"error\"] == _USER_SKIPPED_SENTINEL:\n+            raise OAuthNonInteractiveError(\"user_skipped\")\n+        if result[\"error\"]:\n+            raise RuntimeError(f\"OAuth authorization failed: {result['error']}\")\n+        if result[\"auth_code\"] is None:\n+            raise OAuthNonInteractiveError(\n+                \"OAuth callback timed out — no authorization code received. \"\n+                \"Ensure you completed the browser authorization flow.\"\n+            )\n+\n+        return result[\"auth_code\"], result[\"state\"]\n+\n+    return _wait\n \n \n def _paste_callback_reader(result: dict) -> None:","@@ -1048,17 +1068,18 @@ def build_oauth_auth(\n     client_metadata = _build_client_metadata(cfg)\n     _maybe_preregister_client(storage, cfg, client_metadata)\n \n-    # Use closure factories to avoid global state pollution (#44588).\n+    # Use closure factories to avoid global state pollution (#44588, #34260).\n     resolved_port = cfg.get(\"_resolved_port\", _oauth_port)\n     redirect_handler = _make_redirect_handler(\n         resolved_port, redirect_uri=cfg.get(\"redirect_uri\") or None\n     )\n+    callback_handler = _make_callback_waiter(resolved_port)\n \n     return OAuthClientProvider(\n         server_url=server_url,\n         client_metadata=client_metadata,\n         storage=storage,\n         redirect_handler=redirect_handler,\n-        callback_handler=_wait_for_callback,\n+        callback_handler=callback_handler,\n         timeout=float(cfg.get(\"timeout\", 300)),\n     )"]},{"file":"tools/mcp_oauth_manager.py","hunks":["@@ -522,8 +522,8 @@ def _build_provider(\n             _configure_callback_port,\n             _is_interactive,\n             _maybe_preregister_client,\n+            _make_callback_waiter,\n             _make_redirect_handler,\n-            _wait_for_callback,\n         )\n \n         if not _OAUTH_AVAILABLE:","@@ -547,6 +547,7 @@ def _build_provider(\n \n         resolved_port = cfg.get(\"_resolved_port\", 0)\n         redirect_handler = _make_redirect_handler(resolved_port)\n+        callback_handler = _make_callback_waiter(resolved_port)\n \n         return _HERMES_PROVIDER_CLS(\n             server_name=server_name,","@@ -555,7 +556,7 @@ def _build_provider(\n             client_metadata=client_metadata,\n             storage=storage,\n             redirect_handler=redirect_handler,\n-            callback_handler=_wait_for_callback,\n+            callback_handler=callback_handler,\n             timeout=float(cfg.get(\"timeout\", 300)),\n         )\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}