{"repo":"NousResearch/hermes-agent","public_id":"12400","display_label":"MCP OAuth async generator delegation bug fixes","summary":"A bug in the MCP OAuth provider's async_auth_flow caused NoneType errors due to incorrect generator delegation. Multiple fix attempts address the PEP 525 generator bridging and response forwarding issues.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12717,"merged_at":"2026-04-19T23:31:08+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-19T23:31:08+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":12400,"pr_number":12717,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-19T23:31:08+00:00"}],"members":[{"kind":"issue","number":12400,"title":"[Bug]: MCP OAuth broken for all servers — NoneType has no status_code in HermesMCPOAuthProvider.async_auth_flow","url":null,"state":"closed"},{"kind":"pr","number":12407,"title":"fix(mcp-oauth): forward response in async_auth_flow generator (#12400)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":12409,"title":"fix(mcp): MCP OAuth broken - PEP 525 generator delegation bug (#12400)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a3a49324052c","number":0},{"kind":"pr","value":"#12717","number":12717}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12717,"title":"fix(mcp-oauth): bidirectional auth_flow bridge + absolute expires_at (salvage #12025)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_mcp_oauth_bidirectional.py","tests/tools/test_mcp_oauth_cold_load_expiry.py","tools/mcp_oauth.py","tools/mcp_oauth_manager.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -132,6 +132,7 @@\n     \"[redacted:email]\": \"bryanyoung\",\n     \"[redacted:email]\": \"christomitov\",\n     \"[redacted:email]\": \"NousResearch\",\n+    \"[redacted:email]\": \"benbarclay\",\n     \"[redacted:email]\": \"ChimingLiu\",\n     \"[redacted:email]\": \"openclaw\",\n     \"[redacted:email]\": \"Himess\","]},{"file":"tests/tools/test_mcp_oauth_bidirectional.py","hunks":["@@ -0,0 +1,210 @@\n+\"\"\"Regression test for the ``HermesMCPOAuthProvider.async_auth_flow`` bidirectional\n+generator bridge.\n+\n+PR #11383 introduced a subclass method that wrapped the SDK's ``auth_flow`` with::\n+\n+    async for item in super().async_auth_flow(request):\n+        yield item\n+\n+``httpx``'s auth_flow contract is a **bidirectional** async generator — the\n+driving code (``httpx._client._send_handling_auth``) does::\n+\n+    next_request = await auth_flow.asend(response)\n+\n+to feed HTTP responses back into the generator. The naive ``async for ...``\n+wrapper discards those ``.asend(response)`` values and resumes the inner\n+generator with ``None``, so the SDK's ``response = yield request`` branch in\n+``mcp/client/auth/oauth2.py`` sees ``response = None`` and crashes at\n+``if response.status_code == 401`` with\n+``AttributeError: 'NoneType' object has no attribute 'status_code'``.\n+\n+This broke every OAuth MCP server on the first HTTP response regardless of\n+status code. The reason nothing caught it in CI: zero existing tests drive\n+the full ``.asend()`` round-trip — the integration tests in\n+``test_mcp_oauth_integration.py`` stop at ``_initialize()`` and disk-watching.\n+\n+These tests drive the wrapper through a manual ``.asend()`` sequence to prove\n+the bridge forwards responses correctly into the inner SDK generator.\n+\"\"\"\n+from __future__ import annotations\n+\n+import pytest\n+\n+\n+pytest.importorskip(\"mcp.client.auth.oauth2\", reason=\"MCP SDK 1.26.0+ required\")\n+\n+\n[redacted:email]\n+async def test_hermes_provider_forwards_asend_values(tmp_path, monkeypatch):\n+    \"\"\"The wrapper MUST forward ``.asend(response)`` into the inner generator.\n+\n+    This is the primary regression test. With the broken wrapper, the inner\n+    SDK generator sees ``response = None`` and raises ``AttributeError`` at\n+    ``oauth2.py:505``. With the correct bridge, a 200 response finishes the\n+    flow cleanly (``StopAsyncIteration``).\n+    \"\"\"\n+    import httpx\n+    from mcp.shared.auth import OAuthClientMetadata, OAuthToken\n+    from pydantic import AnyUrl\n+\n+    from tools.mcp_oauth import HermesTokenStorage\n+    from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests\n+\n+    assert _HERMES_PROVIDER_CLS is not None, \"SDK OAuth types must be available\"\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    reset_manager_for_tests()\n+\n+    # Seed a valid-looking token so the SDK's _initialize loads something and\n+    # can_refresh_token() is True (though we don't exercise refresh here — we\n+    # go straight through the 200 path).\n+    storage = HermesTokenStorage(\"srv\")\n+    await storage.set_tokens(\n+        OAuthToken(\n+            access_token=\"[redacted:secret-assignment]\",\n+            token_type=\"[redacted:secret-assignment]\",\n+            expires_in=3600,\n+            refresh_token=\"[redacted:secret-assignment]\",\n+        )\n+    )\n+    # Also seed client_info so the SDK doesn't attempt registration.\n+    from mcp.shared.auth import OAuthClientInformationFull\n+\n+    await storage.set_client_info(\n+        OAuthClientInformationFull(\n+            client_id=\"test-client\",\n+            redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+            grant_types=[\"authorization_code\", \"refresh_token\"],\n+            response_types=[\"code\"],\n+            token_endpoint_auth_method=\"none\",\n+        )\n+    )\n+\n+    metadata = OAuthClientMetadata(\n+        redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+        client_name=\"Hermes Agent\",\n+    )\n+    provider = _HERMES_PROVIDER_CLS(\n+        server_name=\"srv\",\n+        server_url=\"https://example.com/mcp\",\n+        client_metadata=metadata,\n+        storage=storage,\n+        redirect_handler=_noop_redirect,\n+        callback_handler=_noop_callback,\n+    )\n+\n+    req = httpx.Request(\"POST\", \"https://example.com/mcp\")\n+    flow = provider.async_auth_flow(req)\n+\n+    # First anext() drives the wrapper + inner generator until the inner\n+    # yields the outbound request (at oauth2.py:503 ``response = yield request``).\n+    outbound = await flow.__anext__()\n+    assert outbound is not None, \"wrapper must yield the outbound request\"\n+    assert outbound.url.host == \"example.com\"\n+\n+    # Simulate httpx returning a 200 response.\n+    fake_response = httpx.Response(200, request=outbound)\n+\n+    # The broken wrapper would crash here with AttributeError: 'NoneType'\n+    # object has no attribute 'status_code', because the SDK's inner generator\n+    # resumes with response=None and dereferences .status_code at line 505.\n+    #\n+    # The correct wrapper forwards the response, the SDK takes the non-401\n+    # non-403 exit, and the generator ends cleanly (StopAsyncIteration).\n+    with pytest.raises(StopAsyncIteration):\n+        await flow.asend(fake_response)\n+\n+\n[redacted:email]\n+async def test_hermes_provider_forwards_401_triggers_refresh(tmp_path, monkeypatch):\n+    \"\"\"A 401 response MUST flow into the inner generator and trigger the\n+    SDK's 401 recovery branch.\n+\n+    With the broken wrapper, the inner generator sees ``response = None``\n+    and the 401 check short-circuits into AttributeError. With the correct\n+    bridge, the 401 is routed into the SDK's ``response.status_code == 401``\n+    branch which begins discovery (yielding a metadata-discovery request).\n+    \"\"\"\n+    import httpx\n+    from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken\n+    from pydantic import AnyUrl\n+\n+    from tools.mcp_oauth import HermesTokenStorage\n+    from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests\n+\n+    assert _HERMES_PROVIDER_CLS is not None\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    reset_manager_for_tests()\n+\n+    storage = HermesTokenStorage(\"srv\")\n+    await storage.set_tokens(\n+        OAuthToken(\n+            access_token=\"[redacted:secret-assignment]\",\n+            token_type=\"[redacted:secret-assignment]\",\n+            expires_in=3600,\n+            refresh_token=\"[redacted:secret-assignment]\",\n+        )\n+    )\n+    await storage.set_client_info(\n+        OAuthClientInformationFull(\n+            client_id=\"test-client\",\n+            redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+            grant_types=[\"authorization_code\", \"refresh_token\"],\n+            response_types=[\"code\"],\n+            token_endpoint_auth_method=\"none\",\n+        )\n+    )\n+\n+    metadata = OAuthClientMetadata(\n+        redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+        client_name=\"Hermes Agent\",\n+    )\n+    provider = _HERMES_PROVIDER_CLS(\n+        server_name=\"srv\",\n+        server_url=\"https://example.com/mcp\",\n+        client_metadata=metadata,\n+        storage=storage,\n+        redirect_handler=_noop_redirect,\n+        callback_handler=_noop_callback,\n+    )\n+\n+    req = httpx.Request(\"POST\", \"https://example.com/mcp\")\n+    flow = provider.async_auth_flow(req)\n+\n+    # Drive to the first yield (outbound MCP request).\n+    outbound = await flow.__anext__()\n+\n+    # Reply with a 401 including a minimal WWW-Authenticate so the SDK's\n+    # 401 branch can parse resource metadata from it. We just need something\n+    # the SDK accepts before it tries to yield the metadata-discovery request.\n+    fake_401 = httpx.Response(\n+        401,\n+        request=outbound,\n+        headers={\"www-authenticate\": 'Bearer resource_metadata=\"https://example.com/.well-known/oauth-protected-resource\"'},\n+    )\n+\n+    # The correct bridge forwards the 401 into the SDK; the SDK then yields\n+    # its NEXT request (a metadata-discovery GET). We assert we get a request\n+    # back — any request. The broken bridge would have crashed with\n+    # AttributeError before we ever reach this point.\n+    next_request = await flow.asend(fake_401)\n+    assert isinstance(next_request, httpx.Request), (\n+        \"wrapper must forward .asend() so the SDK's 401 branch can yield the \"\n+        \"next request in the discovery flow\"\n+    )\n+\n+    # Clean up the generator — we don't need to complete the full dance.\n+    await flow.aclose()\n+\n+\n+async def _noop_redirect(_url: str) -> None:\n+    \"\"\"Redirect handler that does nothing (won't be invoked in these tests).\"\"\"\n+    return None\n+\n+\n+async def _noop_callback() -> tuple[str, str | None]:\n+    \"\"\"Callback handler that won't be invoked in these tests.\"\"\"\n+    raise AssertionError(\n+        \"callback handler should not be invoked in bidirectional-generator tests\"\n+    )"]},{"file":"tests/tools/test_mcp_oauth_cold_load_expiry.py","hunks":["@@ -0,0 +1,546 @@\n+\"\"\"Tests for cold-load token expiry tracking in MCP OAuth.\n+\n+PR #11383's consolidation fixed external-refresh reloading (mtime disk-watch)\n+and 401 dedup, but left two underlying latent bugs in place:\n+\n+1. ``HermesTokenStorage.set_tokens`` persisted only relative ``expires_in``,\n+   which is meaningless after a process restart.\n+2. The MCP SDK's ``OAuthContext._initialize`` loads ``current_tokens`` from\n+   storage but does NOT call ``update_token_expiry``, so\n+   ``token_expiry_time`` stays None. ``is_token_valid()`` then returns True\n+   for any loaded token regardless of actual age, and the SDK's preemptive\n+   refresh branch at ``oauth2.py:491`` is never taken.\n+\n+Consequence: a token that expired while the process was down ships to the\n+server with a stale Bearer header. The server's response is provider-specific\n+— some return HTTP 401 (caught by the consolidation's 401 handler, which\n+surfaces a ``needs_reauth`` error), others return HTTP 200 with an\n+application-level auth failure in the body (e.g. BetterStack's \"No teams\n+found. Please check your authentication.\"), which the consolidation cannot\n+detect.\n+\n+These tests pin the contract for Fix A:\n+- ``set_tokens`` persists an absolute ``expires_at`` wall-clock timestamp.\n+- ``get_tokens`` reconstructs ``expires_in`` from ``expires_at - now`` so\n+  the SDK's ``update_token_expiry`` computes the correct absolute expiry.\n+- ``HermesMCPOAuthProvider._initialize`` seeds ``context.token_expiry_time``\n+  after loading, so ``is_token_valid()`` reports True only for tokens that\n+  are actually still valid, and the SDK's preemptive refresh fires for\n+  expired tokens with a live refresh_token.\n+\n+Reference: Claude Code solves this via an ``OAuthTokens.expiresAt`` absolute\n+timestamp persisted alongside the access_token (``auth.ts:~180``).\n+\"\"\"\n+from __future__ import annotations\n+\n+import asyncio\n+import json\n+import time\n+\n+import pytest\n+\n+\n+pytest.importorskip(\"mcp.client.auth.oauth2\", reason=\"MCP SDK 1.26.0+ required\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# HermesTokenStorage — absolute expiry persistence\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestSetTokensAbsoluteExpiry:\n+    def test_set_tokens_persists_absolute_expires_at(self, tmp_path, monkeypatch):\n+        \"\"\"Tokens round-tripped through disk must encode absolute expiry.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from mcp.shared.auth import OAuthToken\n+\n+        from tools.mcp_oauth import HermesTokenStorage\n+\n+        storage = HermesTokenStorage(\"srv\")\n+        before = time.time()\n+        asyncio.run(\n+            storage.set_tokens(\n+                OAuthToken(\n+                    access_token=\"a\",\n+                    token_type=\"[redacted:secret-assignment]\",\n+                    expires_in=3600,\n+                    refresh_token=\"r\",\n+                )\n+            )\n+        )\n+        after = time.time()\n+\n+        on_disk = json.loads(\n+            (tmp_path / \"mcp-tokens\" / \"srv.json\").read_text()\n+        )\n+        assert \"expires_at\" in on_disk, (\n+            \"Fix A: set_tokens must record an absolute expires_at wall-clock \"\n+            \"timestamp alongside the SDK's serialized token so cold-loads \"\n+            \"can compute correct remaining TTL.\"\n+        )\n+        assert before + 3600 <= on_disk[\"expires_at\"] <= after + 3600\n+\n+    def test_set_tokens_without_expires_in_omits_expires_at(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Tokens without a TTL must not gain a fabricated expires_at.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from mcp.shared.auth import OAuthToken\n+\n+        from tools.mcp_oauth import HermesTokenStorage\n+\n+        storage = HermesTokenStorage(\"srv\")\n+        asyncio.run(\n+            storage.set_tokens(\n+                OAuthToken(\n+                    access_token=\"a\",\n+                    token_type=\"[redacted:secret-assignment]\",\n+                    refresh_token=\"r\",\n+                )\n+            )\n+        )\n+\n+        on_disk = json.loads(\n+            (tmp_path / \"mcp-tokens\" / \"srv.json\").read_text()\n+        )\n+        assert \"expires_at\" not in on_disk\n+\n+\n+class TestGetTokensReconstructsExpiresIn:\n+    def test_get_tokens_uses_expires_at_for_remaining_ttl(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Round-trip: expires_in on read must reflect time remaining.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from mcp.shared.auth import OAuthToken\n+\n+        from tools.mcp_oauth import HermesTokenStorage\n+\n+        storage = HermesTokenStorage(\"srv\")\n+        asyncio.run(\n+            storage.set_tokens(\n+                OAuthToken(\n+                    access_token=\"a\",\n+                    token_type=\"[redacted:secret-assignment]\",\n+                    expires_in=3600,\n+                    refresh_token=\"r\",\n+                )\n+            )\n+        )\n+\n+        # Wait briefly so the remaining TTL is measurably less than 3600.\n+        time.sleep(0.05)\n+\n+        reloaded = asyncio.run(storage.get_tokens())\n+        assert reloaded is not None\n+        assert reloaded.expires_in is not None\n+        # Should be slightly less than 3600 after the 50ms sleep.\n+        assert 3500 < reloaded.expires_in <= 3600\n+\n+    def test_get_tokens_returns_zero_ttl_for_expired_token(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"An already-expired token reloaded from disk must report expires_in=0.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_oauth import HermesTokenStorage, _get_token_dir\n+\n+        token_dir = _get_token_dir()\n+        token_dir.mkdir(parents=True, exist_ok=True)\n+        # Write an already-expired token file directly.\n+        (token_dir / \"srv.json\").write_text(\n+            json.dumps(\n+                {\n+                    \"access_token\": \"a\",\n+                    \"token_type\": \"[redacted:secret-assignment]\",\n+                    \"expires_in\": 3600,\n+                    \"expires_at\": time.time() - 60,  # expired 1 min ago\n+                    \"refresh_token\": \"r\",\n+                }\n+            )\n+        )\n+\n+        storage = HermesTokenStorage(\"srv\")\n+        reloaded = asyncio.run(storage.get_tokens())\n+        assert reloaded is not None\n+        assert reloaded.expires_in == 0, (\n+            \"Expired token must reload with expires_in=0 so the SDK's \"\n+            \"is_token_valid() returns False and preemptive refresh fires.\"\n+        )\n+\n+    def test_get_tokens_legacy_file_without_expires_at_is_loadable(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Existing on-disk files (pre-Fix-A) must still load without crashing.\n+\n+        Pre-existing token files have ``expires_in`` but no ``expires_at``.\n+        Fix A falls back to the file's mtime as a best-effort wall-clock\n+        proxy: a file whose (mtime + expires_in) is in the past clamps\n+        expires_in to zero so the SDK refreshes on next request. A fresh\n+        legacy-format file (mtime = now) keeps most of its TTL.\n+        \"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        from tools.mcp_oauth import HermesTokenStorage, _get_token_dir\n+\n+        token_dir = _get_token_dir()\n+        token_dir.mkdir(parents=True, exist_ok=True)\n+        # Legacy-shape file (no expires_at). Make it stale by backdating mtime\n+        # well past its nominal expires_in.\n+        legacy_path = token_dir / \"srv.json\"\n+        legacy_path.write_text(\n+            json.dumps(\n+                {\n+                    \"access_token\": \"a\",\n+                    \"token_type\": \"[redacted:secret-assignment]\",\n+                    \"expires_in\": 3600,\n+                    \"refresh_token\": \"r\",\n+                }\n+            )\n+        )\n+        stale_time = time.time() - 7200  # 2hr ago, exceeds 3600s TTL\n+        import os\n+\n+        os.utime(legacy_path, (stale_time, stale_time))\n+\n+        storage = HermesTokenStorage(\"srv\")\n+        reloaded = asyncio.run(storage.get_tokens())\n+        assert reloaded is not None\n+        assert reloaded.expires_in == 0, (\n+            \"Legacy file whose mtime + expires_in is in the past must report \"\n+            \"expires_in=0 so the SDK refreshes on next request.\"\n+        )\n+\n+\n+# ---------------------------------------------------------------------------\n+# HermesMCPOAuthProvider._initialize — seed token_expiry_time\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_initialize_seeds_token_expiry_time_from_stored_tokens(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"Cold-load must populate context.token_expiry_time.\n+\n+    The SDK's base ``_initialize`` loads current_tokens but doesn't seed\n+    token_expiry_time. Our subclass must do it so ``is_token_valid()``\n+    reports correctly and the preemptive-refresh path fires when needed.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    from mcp.shared.auth import OAuthClientInformationFull, OAuthToken\n+    from pydantic import AnyUrl\n+\n+    from tools.mcp_oauth import HermesTokenStorage\n+    from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests\n+\n+    assert _HERMES_PROVIDER_CLS is not None\n+    reset_manager_for_tests()\n+\n+    storage = HermesTokenStorage(\"srv\")\n+    await storage.set_tokens(\n+        OAuthToken(\n+            access_token=\"a\",\n+            token_type=\"[redacted:secret-assignment]\",\n+            expires_in=7200,\n+            refresh_token=\"r\",\n+        )\n+    )\n+    await storage.set_client_info(\n+        OAuthClientInformationFull(\n+            client_id=\"test-client\",\n+            redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+            grant_types=[\"authorization_code\", \"refresh_token\"],\n+            response_types=[\"code\"],\n+            token_endpoint_auth_method=\"none\",\n+        )\n+    )\n+\n+    from mcp.shared.auth import OAuthClientMetadata\n+\n+    metadata = OAuthClientMetadata(\n+        redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+        client_name=\"Hermes Agent\",\n+    )\n+    provider = _HERMES_PROVIDER_CLS(\n+        server_name=\"srv\",\n+        server_url=\"https://example.com/mcp\",\n+        client_metadata=metadata,\n+        storage=storage,\n+        redirect_handler=_noop_redirect,\n+        callback_handler=_noop_callback,\n+    )\n+\n+    await provider._initialize()\n+\n+    assert provider.context.token_expiry_time is not None, (\n+        \"Fix A: _initialize must seed context.token_expiry_time so \"\n+        \"is_token_valid() correctly reports expiry on cold-load.\"\n+    )\n+    # Should be ~7200s in the future (fresh write).\n+    assert provider.context.token_expiry_time > time.time() + 7000\n+    assert provider.context.token_expiry_time <= time.time() + 7200 + 5\n+\n+\n[redacted:email]\n+async def test_initialize_flags_expired_token_as_invalid(tmp_path, monkeypatch):\n+    \"\"\"After _initialize, an expired-on-disk token must report is_token_valid=[redacted:secret-assignment]\n+\n+    This is the end-to-end assertion: cold-load an expired token, verify the\n+    SDK's own ``is_token_valid()`` now returns False (the consequence of\n+    seeding token_expiry_time correctly), so the SDK's ``async_auth_flow``\n+    will take the ``can_refresh_token()`` branch on the next request and\n+    silently refresh instead of sending the stale Bearer.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata\n+    from pydantic import AnyUrl\n+\n+    from tools.mcp_oauth import HermesTokenStorage, _get_token_dir\n+    from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests\n+\n+    assert _HERMES_PROVIDER_CLS is not None\n+    reset_manager_for_tests()\n+\n+    # Write an already-expired token directly so we control the wall-clock.\n+    token_dir = _get_token_dir()\n+    token_dir.mkdir(parents=True, exist_ok=True)\n+    (token_dir / \"srv.json\").write_text(\n+        json.dumps(\n+            {\n+                \"access_token\": \"stale\",\n+                \"token_type\": \"[redacted:secret-assignment]\",\n+                \"expires_in\": 3600,\n+                \"expires_at\": time.time() - 60,\n+                \"refresh_token\": \"fresh\",\n+            }\n+        )\n+    )\n+\n+    storage = HermesTokenStorage(\"srv\")\n+    await storage.set_client_info(\n+        OAuthClientInformationFull(\n+            client_id=\"test-client\",\n+            redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+            grant_types=[\"authorization_code\", \"refresh_token\"],\n+            response_types=[\"code\"],\n+            token_endpoint_auth_method=\"none\",\n+        )\n+    )\n+\n+    metadata = OAuthClientMetadata(\n+        redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+        client_name=\"Hermes Agent\",\n+    )\n+    provider = _HERMES_PROVIDER_CLS(\n+        server_name=\"srv\",\n+        server_url=\"https://example.com/mcp\",\n+        client_metadata=metadata,\n+        storage=storage,\n+        redirect_handler=_noop_redirect,\n+        callback_handler=_noop_callback,\n+    )\n+\n+    await provider._initialize()\n+\n+    assert provider.context.is_token_valid() is False, (\n+        \"After _initialize with an expired-on-disk token, is_token_valid() \"\n+        \"must return False so the SDK's async_auth_flow takes the \"\n+        \"preemptive refresh path.\"\n+    )\n+    assert provider.context.can_refresh_token() is True, (\n+        \"Refresh should remain possible because refresh_token + client_info \"\n+        \"are both present.\"\n+    )\n+\n+\n+async def _noop_redirect(_url: str) -> None:\n+    return None\n+\n+\n+async def _noop_callback() -> tuple[str, str | None]:\n+    raise AssertionError(\"callback handler should not be invoked in these tests\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pre-flight OAuth metadata discovery\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_initialize_prefetches_oauth_metadata_when_missing(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"Cold-load must pre-flight PRM + ASM discovery so ``_refresh_token``\n+    has the correct ``token_endpoint`` before the first refresh attempt.\n+\n+    Without this, the SDK's ``_refresh_token`` falls back to\n+    ``{server_url}/token`` which is wrong for providers whose AS is at\n+    a different origin. BetterStack specifically: MCP at\n+    ``mcp.betterstack.com`` but token_endpoint at\n+    ``betterstack.com/oauth/token``. Without pre-flight the refresh 404s\n+    and we drop into full browser re-auth — visible to the user as an\n+    unwanted OAuth browser prompt every time the process restarts.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    import httpx\n+    from mcp.shared.auth import (\n+        OAuthClientInformationFull,\n+        OAuthClientMetadata,\n+        OAuthToken,\n+    )\n+    from pydantic import AnyUrl\n+\n+    from tools.mcp_oauth import HermesTokenStorage\n+    from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests\n+\n+    assert _HERMES_PROVIDER_CLS is not None\n+    reset_manager_for_tests()\n+\n+    storage = HermesTokenStorage(\"srv\")\n+    await storage.set_tokens(\n+        OAuthToken(\n+            access_token=\"a\",\n+            token_type=\"[redacted:secret-assignment]\",\n+            expires_in=3600,\n+            refresh_token=\"r\",\n+        )\n+    )\n+    await storage.set_client_info(\n+        OAuthClientInformationFull(\n+            client_id=\"test-client\",\n+            redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+            grant_types=[\"authorization_code\", \"refresh_token\"],\n+            response_types=[\"code\"],\n+            token_endpoint_auth_method=\"none\",\n+        )\n+    )\n+\n+    # Route the AsyncClient used inside _prefetch_oauth_metadata through a\n+    # MockTransport that mimics BetterStack's split-origin discovery:\n+    #   PRM at mcp.example.com/.well-known/oauth-protected-resource -> points to auth.example.com\n+    #   ASM at auth.example.com/.well-known/oauth-authorization-server -> token_endpoint at auth.example.com/oauth/token\n+    def mock_handler(request: httpx.Request) -> httpx.Response:\n+        url = str(request.url)\n+        if url.endswith(\"/.well-known/oauth-protected-resource\"):\n+            return httpx.Response(\n+                200,\n+                json={\n+                    \"resource\": \"https://mcp.example.com\",\n+                    \"authorization_servers\": [\"https://auth.example.com\"],\n+                    \"scopes_supported\": [\"read\", \"write\"],\n+                    \"bearer_methods_supported\": [\"header\"],\n+                },\n+            )\n+        if url.endswith(\"/.well-known/oauth-authorization-server\"):\n+            return httpx.Response(\n+                200,\n+                json={\n+                    \"issuer\": \"https://auth.example.com\",\n+                    \"authorization_endpoint\": \"https://auth.example.com/oauth/authorize\",\n+                    \"token_endpoint\": \"[redacted:secret-assignment]\",\n+                    \"registration_endpoint\": \"https://auth.example.com/oauth/register\",\n+                    \"response_types_supported\": [\"code\"],\n+                    \"grant_types_supported\": [\"authorization_code\", \"refresh_token\"],\n+                    \"code_challenge_methods_supported\": [\"S256\"],\n+                    \"token_endpoint_auth_methods_supported\": [\"none\"],\n+                    \"scopes_supported\": [\"read\", \"write\"],\n+                },\n+            )\n+        return httpx.Response(404)\n+\n+    transport = httpx.MockTransport(mock_handler)\n+\n+    # Patch the AsyncClient constructor used by _prefetch_oauth_metadata so\n+    # it uses our mock transport instead of the real network.\n+    import httpx as real_httpx\n+\n+    original_async_client = real_httpx.AsyncClient\n+\n+    def patched_async_client(*args, **kwargs):\n+        kwargs[\"transport\"] = transport\n+        return original_async_client(*args, **kwargs)\n+\n+    monkeypatch.setattr(real_httpx, \"AsyncClient\", patched_async_client)\n+\n+    metadata = OAuthClientMetadata(\n+        redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+        client_name=\"Hermes Agent\",\n+    )\n+    provider = _HERMES_PROVIDER_CLS(\n+        server_name=\"srv\",\n+        server_url=\"https://mcp.example.com\",\n+        client_metadata=metadata,\n+        storage=storage,\n+        redirect_handler=_noop_redirect,\n+        callback_handler=_noop_callback,\n+    )\n+\n+    await provider._initialize()\n+\n+    assert provider.context.protected_resource_metadata is not None, (\n+        \"Pre-flight must cache PRM for the SDK to reference later.\"\n+    )\n+    assert provider.context.oauth_metadata is not None, (\n+        \"Pre-flight must cache ASM so _refresh_token builds the correct \"\n+        \"token_endpoint URL.\"\n+    )\n+    assert str(provider.context.oauth_metadata.token_endpoint) == (\n+        \"https://auth.example.com/oauth/token\"\n+    )\n+\n+\n[redacted:email]\n+async def test_initialize_skips_prefetch_when_no_tokens(tmp_path, monkeypatch):\n+    \"\"\"Pre-flight must not run when there are no stored tokens yet.\n+\n+    Without this guard, every fresh-install ``_initialize`` would do two\n+    extra network roundtrips that gain nothing (the SDK's 401-branch\n+    discovery will run on the first real request anyway).\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    import httpx\n+    from mcp.shared.auth import OAuthClientMetadata\n+    from pydantic import AnyUrl\n+\n+    from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests\n+    from tools.mcp_oauth import HermesTokenStorage\n+\n+    assert _HERMES_PROVIDER_CLS is not None\n+    reset_manager_for_tests()\n+\n+    calls: list[str] = []\n+\n+    def mock_handler(request: httpx.Request) -> httpx.Response:\n+        calls.append(str(request.url))\n+        return httpx.Response(404)\n+\n+    transport = httpx.MockTransport(mock_handler)\n+    import httpx as real_httpx\n+\n+    original = real_httpx.AsyncClient\n+\n+    def patched(*args, **kwargs):\n+        kwargs[\"transport\"] = transport\n+        return original(*args, **kwargs)\n+\n+    monkeypatch.setattr(real_httpx, \"AsyncClient\", patched)\n+\n+    storage = HermesTokenStorage(\"srv\")  # empty — no tokens on disk\n+    metadata = OAuthClientMetadata(\n+        redirect_uris=[AnyUrl(\"http://127.0.0.1:12345/callback\")],\n+        client_name=\"Hermes Agent\",\n+    )\n+    provider = _HERMES_PROVIDER_CLS(\n+        server_name=\"srv\",\n+        server_url=\"https://mcp.example.com\",\n+        client_metadata=metadata,\n+        storage=storage,\n+        redirect_handler=_noop_redirect,\n+        callback_handler=_noop_callback,\n+    )\n+\n+    await provider._initialize()\n+\n+    assert calls == [], (\n+        f\"Pre-flight must not fire when no tokens are stored, but got {calls}\"\n+    )"]},{"file":"tools/mcp_oauth.py","hunks":["@@ -40,6 +40,7 @@\n import socket\n import sys\n import threading\n+import time\n import webbrowser\n from http.server import BaseHTTPRequestHandler, HTTPServer\n from pathlib import Path","@@ -196,6 +197,35 @@ async def get_tokens(self) -> \"OAuthToken | None\":\n         data = _read_json(self._tokens_path())\n         if data is None:\n             return None\n+        # Hermes records an absolute wall-clock ``expires_at`` alongside the\n+        # SDK's serialized token (see ``set_tokens``). On read we rewrite\n+        # ``expires_in`` to the remaining seconds so the SDK's downstream\n+        # ``update_token_expiry`` computes the correct absolute time and\n+        # ``is_token_valid()`` correctly reports False for tokens that\n+        # expired while the process was down.\n+        #\n+        # Legacy token files (pre-Fix-A) have ``expires_in`` but no\n+        # ``expires_at``. We fall back to the file's mtime as a best-effort\n+        # wall-clock proxy for when the token was written: if (mtime +\n+        # expires_in) is in the past, clamp ``expires_in`` to zero so the\n+        # SDK refreshes before the first request. This self-heals one-time\n+        # on the next successful ``set_tokens``, which writes the new\n+        # ``expires_at`` field. The stored ``expires_at`` is stripped before\n+        # model_validate because it's not part of the SDK's OAuthToken schema.\n+        absolute_expiry = data.pop(\"expires_at\", None)\n+        if absolute_expiry is not None:\n+            data[\"expires_in\"] = int(max(absolute_expiry - time.time(), 0))\n+        elif data.get(\"expires_in\") is not None:\n+            try:\n+                file_mtime = self._tokens_path().stat().st_mtime\n+            except OSError:\n+                file_mtime = None\n+            if file_mtime is not None:\n+                try:\n+                    implied_expiry = file_mtime + int(data[\"expires_in\"])\n+                    data[\"expires_in\"] = int(max(implied_expiry - time.time(), 0))\n+                except (TypeError, ValueError):\n+                    pass\n         try:\n             return OAuthToken.model_validate(data)\n         except (ValueError, TypeError, KeyError) as exc:","@@ -203,7 +233,23 @@ async def get_tokens(self) -> \"OAuthToken | None\":\n             return None\n \n     async def set_tokens(self, tokens: \"[redacted:secret-assignment]\") -> None:\n-        _write_json(self._tokens_path(), tokens.model_dump(exclude_none=True))\n+        payload = tokens.model_dump(exclude_none=True)\n+        # Persist an absolute ``expires_at`` so a process restart can\n+        # reconstruct the correct remaining TTL. Without this the MCP SDK's\n+        # ``_initialize`` reloads a relative ``expires_in`` which has no\n+        # wall-clock reference, leaving ``context.token_expiry_time=[redacted:secret-assignment]\n+        # and ``is_token_valid()`` falsely reporting True. See Fix A in\n+        # ``mcp-oauth-token-diagnosis`` skill + Claude Code's\n+        # ``OAuthTokens.expiresAt`` persistence (auth.ts ~180).\n+        expires_in = payload.get(\"expires_in\")\n+        if expires_in is not None:\n+            try:\n+                payload[\"expires_at\"] = time.time() + int(expires_in)\n+            except (TypeError, ValueError):\n+                # Mock tokens or unusual shapes: skip the expires_at write\n+                # rather than fail persistence.\n+                pass\n+        _write_json(self._tokens_path(), payload)\n         logger.debug(\"OAuth tokens saved for %s\", self._server_name)\n \n     # -- client info -------------------------------------------------------"]},{"file":"tools/mcp_oauth_manager.py","hunks":["@@ -111,6 +111,131 @@ def __init__(self, *args: Any, server_name: str = \"\", **kwargs: Any):\n             super().__init__(*args, **kwargs)\n             self._hermes_server_name = server_name\n \n+        async def _initialize(self) -> None:\n+            \"\"\"Load stored tokens + client info AND seed token_expiry_time.\n+\n+            Also eagerly fetches OAuth authorization-server metadata (PRM +\n+            ASM) when we have stored tokens but no cached metadata, so the\n+            SDK's ``_refresh_token`` can build the correct token_endpoint\n+            URL on the preemptive-refresh path. Without this, the SDK\n+            falls back to ``{mcp_server_url}/token`` (wrong for providers\n+            whose AS is a different origin — BetterStack's MCP lives at\n+            ``https://mcp.betterstack.com`` but its token endpoint is at\n+            ``https://betterstack.com/oauth/token``), the refresh 404s, and\n+            we drop through to full browser reauth.\n+\n+            The SDK's base ``_initialize`` populates ``current_tokens`` but\n+            does NOT call ``update_token_expiry``, so ``token_expiry_time``\n+            stays ``None`` and ``is_token_valid()`` returns True for any\n+            loaded token regardless of actual age. After a process restart\n+            this ships stale Bearer tokens to the server; some providers\n+            return HTTP 401 (caught by the 401 handler), others return 200\n+            with an app-level auth error (invisible to the transport layer,\n+            e.g. BetterStack returning \"No teams found. Please check your\n+            authentication.\").\n+\n+            Seeding ``token_expiry_time`` from the reloaded token fixes that:\n+            ``is_token_valid()`` correctly reports False for expired tokens,\n+            ``async_auth_flow`` takes the ``can_refresh_token()`` branch,\n+            and the SDK quietly refreshes before the first real request.\n+\n+            Paired with :class:`HermesTokenStorage` persisting an absolute\n+            ``expires_at`` timestamp (``mcp_oauth.py:set_tokens``) so the\n+            remaining TTL we compute here reflects real wall-clock age.\n+            \"\"\"\n+            await super()._initialize()\n+            tokens = self.context.current_tokens\n+            if tokens is not None and tokens.expires_in is not None:\n+                self.context.update_token_expiry(tokens)\n+\n+            # Pre-flight OAuth AS discovery so ``_refresh_token`` has a\n+            # correct ``token_endpoint`` before the first refresh attempt.\n+            # Only runs when we have tokens on cold-load but no cached\n+            # metadata — i.e. the exact scenario where the SDK's built-in\n+            # 401-branch discovery hasn't had a chance to run yet.\n+            if (\n+                tokens is not None\n+                and self.context.oauth_metadata is None\n+            ):\n+                try:\n+                    await self._prefetch_oauth_metadata()\n+                except Exception as exc:  # pragma: no cover — defensive\n+                    # Non-fatal: if discovery fails, the SDK's normal 401-\n+                    # branch discovery will run on the next request.\n+                    logger.debug(\n+                        \"MCP OAuth '%s': pre-flight metadata discovery \"\n+                        \"failed (non-fatal): %s\",\n+                        self._hermes_server_name, exc,\n+                    )\n+\n+        async def _prefetch_oauth_metadata(self) -> None:\n+            \"\"\"Fetch PRM + ASM from the well-known endpoints, cache on context.\n+\n+            Mirrors the SDK's 401-branch discovery (oauth2.py ~line 511-551)\n+            but runs synchronously before the first request instead of\n+            inside the httpx auth_flow generator. Uses the SDK's own URL\n+            builders and response handlers so we track whatever the SDK\n+            version we're pinned to expects.\n+            \"\"\"\n+            import httpx  # local import: httpx is an MCP SDK dependency\n+            from mcp.client.auth.utils import (\n+                build_oauth_authorization_server_metadata_discovery_urls,\n+                build_protected_resource_metadata_discovery_urls,\n+                create_oauth_metadata_request,\n+                handle_auth_metadata_response,\n+                handle_protected_resource_response,\n+            )\n+\n+            server_url = self.context.server_url\n+            async with httpx.AsyncClient(timeout=10.0) as client:\n+                # Step 1: PRM discovery to learn the authorization_server URL.\n+                for url in build_protected_resource_metadata_discovery_urls(\n+                    None, server_url\n+                ):\n+                    req = create_oauth_metadata_request(url)\n+                    try:\n+                        resp = await client.send(req)\n+                    except httpx.HTTPError as exc:\n+                        logger.debug(\n+                            \"MCP OAuth '%s': PRM discovery to %s failed: %s\",\n+                            self._hermes_server_name, url, exc,\n+                        )\n+                        continue\n+                    prm = await handle_protected_resource_response(resp)\n+                    if prm:\n+                        self.context.protected_resource_metadata = prm\n+                        if prm.authorization_servers:\n+                            self.context.auth_server_url = str(\n+                                prm.authorization_servers[0]\n+                            )\n+                        break\n+\n+                # Step 2: ASM discovery against the auth_server_url (or\n+                # server_url fallback for legacy providers).\n+                for url in build_oauth_authorization_server_metadata_discovery_urls(\n+                    self.context.auth_server_url, server_url\n+                ):\n+                    req = create_oauth_metadata_request(url)\n+                    try:\n+                        resp = await client.send(req)\n+                    except httpx.HTTPError as exc:\n+                        logger.debug(\n+                            \"MCP OAuth '%s': ASM discovery to %s failed: %s\",\n+                            self._hermes_server_name, url, exc,\n+                        )\n+                        continue\n+                    ok, asm = await handle_auth_metadata_response(resp)\n+                    if not ok:\n+                        break\n+                    if asm:\n+                        self.context.oauth_metadata = asm\n+                        logger.debug(\n+                            \"MCP OAuth '%s': pre-flight ASM discovered \"\n+                            \"token_endpoint=%s\",\n+                            self._hermes_server_name, asm.token_endpoint,\n+                        )\n+                        break\n+\n         async def async_auth_flow(self, request):  # type: ignore[override]\n             # Pre-flow hook: ask the manager to refresh from disk if needed.\n             # Any failure here is non-fatal — we just log and proceed with","@@ -125,9 +250,28 @@ async def async_auth_flow(self, request):  # type: ignore[override]\n                     self._hermes_server_name, exc,\n                 )\n \n-            # Delegate to the SDK's auth flow\n-            async for item in super().async_auth_flow(request):\n-                yield item\n+            # Manually bridge the bidirectional generator protocol. httpx's\n+            # auth_flow driver (httpx._client._send_handling_auth) calls\n+            # ``auth_flow.asend(response)`` to feed HTTP responses back into\n+            # the generator. A naive wrapper using ``async for item in inner:\n+            # yield item`` DISCARDS those .asend(response) values and resumes\n+            # the inner generator with None, so the SDK's\n+            # ``response = yield request`` branch in\n+            # mcp/client/auth/oauth2.py sees response=None and crashes at\n+            # ``if response.status_code == 401`` with AttributeError.\n+            #\n+            # The bridge below forwards each .asend() value into the inner\n+            # generator via inner.asend(incoming), preserving the bidirectional\n+            # contract. Regression from PR #11383 caught by\n+            # tests/tools/test_mcp_oauth_bidirectional.py.\n+            inner = super().async_auth_flow(request)\n+            try:\n+                outgoing = await inner.__anext__()\n+                while True:\n+                    incoming = yield outgoing\n+                    outgoing = await inner.asend(incoming)\n+            except StopAsyncIteration:\n+                return\n \n     return HermesMCPOAuthProvider\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}