{"repo":"NousResearch/hermes-agent","public_id":"36767","display_label":"MCP OAuth stale client registration auto-recovery","summary":"These issues and PRs concern automatic recovery when MCP OAuth fails due to stale or invalid dynamic client registrations, including the invalid_client error and redirect URI mismatch scenarios.","subsystem":"mcp","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52936,"merged_at":"2026-06-26T07:35:28+00:00"},"resolution":{"state":"resolved","by":"pr-52936"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T07:35:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":36767,"pr_number":52936,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-26T07:35:28+00:00"}],"members":[{"kind":"issue","number":36767,"title":"MCP OAuth: no auto-recovery from invalid_client / 'Redirect URI Mismatch' on stale dynamic client registration","url":null,"state":"closed"},{"kind":"pr","number":37096,"title":"fix(mcp): auto-recover from invalid_client on stale OAuth client registration","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"075f93ad7","number":0},{"kind":"pr","value":"#52936","number":52936}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52936,"title":"fix(mcp): auto-recover from invalid_client on stale OAuth client registration (salvage #37096)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/mcp_config.py","hermes_cli/subcommands/mcp.py","tests/hermes_cli/test_mcp_config.py","tests/tools/test_mcp_oauth.py","tests/tools/test_mcp_oauth_manager.py","tools/mcp_oauth.py","tools/mcp_oauth_manager.py"],"fix_hunks":[{"file":"hermes_cli/mcp_config.py","hunks":["@@ -665,44 +665,28 @@ def cmd_mcp_test(args):\n \n # ─── hermes mcp login ────────────────────────────────────────────────────────\n \n-def cmd_mcp_login(args):\n-    \"\"\"Force re-authentication for an OAuth-based MCP server.\n+def _reauth_oauth_server(name: str, server_config: dict) -> bool:\n+    \"\"\"Force a fresh OAuth flow for one server. Returns True on success.\n \n-    Deletes cached tokens (both on disk and in the running process's\n-    MCPOAuthManager cache) and triggers a fresh OAuth flow via the\n-    existing probe path.\n-\n-    Use this when:\n-      - Tokens are stuck in a bad state (server revoked, refresh token\n-        consumed by an external process, etc.)\n-      - You want to re-authenticate to change scopes or account\n-      - A tool call returned ``needs_reauth: true``\n+    Wipes cached OAuth state (disk + in-process MCPOAuthManager cache),\n+    re-probes to trigger the browser flow, and verifies a token actually\n+    landed before reporting success. Shared by ``hermes mcp login`` and\n+    ``hermes mcp reauth`` so both behave identically for a single server.\n     \"\"\"\n-    name = args.name\n-    servers = _get_mcp_servers()\n-\n-    if name not in servers:\n-        _error(f\"Server '{name}' not found in config.\")\n-        if servers:\n-            _info(f\"Available servers: {', '.join(servers)}\")\n-        return\n-\n-    server_config = servers[name]\n     url = server_config.get(\"url\")\n     if not url:\n         _error(f\"Server '{name}' has no URL — not an OAuth-capable server\")\n-        return\n+        return False\n     if server_config.get(\"auth\") != \"oauth\":\n         _error(f\"Server '{name}' is not configured for OAuth (auth={server_config.get('auth')})\")\n         _info(\"Use `hermes mcp remove` + `hermes mcp add` to reconfigure auth.\")\n-        return\n+        return False\n \n     # Wipe both disk and in-memory cache so the next probe forces a fresh\n     # OAuth flow.\n     try:\n         from tools.mcp_oauth_manager import get_manager\n-        mgr = get_manager()\n-        mgr.remove(name)\n+        get_manager().remove(name)\n     except Exception as exc:\n         _warning(f\"Could not clear existing OAuth state: {exc}\")\n ","@@ -741,13 +725,90 @@ def cmd_mcp_login(args):\n             print(color(f\"          client_secret: \\\"[redacted:secret-assignment]\"\", Colors.DIM))\n             print()\n             _info(\"Then re-run `hermes mcp login \" + name + \"`.\")\n-            return\n+            return False\n         if tools:\n             _success(f\"Authenticated — {len(tools)} tool(s) available\")\n         else:\n             _success(\"Authenticated (server reported no tools)\")\n+        return True\n     except Exception as exc:\n         _error(f\"Authentication failed: {exc}\")\n+        return False\n+\n+\n+def cmd_mcp_login(args):\n+    \"\"\"Force re-authentication for an OAuth-based MCP server.\n+\n+    Deletes cached tokens (both on disk and in the running process's\n+    MCPOAuthManager cache) and triggers a fresh OAuth flow via the\n+    existing probe path.\n+\n+    Use this when:\n+      - Tokens are stuck in a bad state (server revoked, refresh token\n+        consumed by an external process, etc.)\n+      - You want to re-authenticate to change scopes or account\n+      - A tool call returned ``needs_reauth: true``\n+    \"\"\"\n+    name = args.name\n+    servers = _get_mcp_servers()\n+\n+    if name not in servers:\n+        _error(f\"Server '{name}' not found in config.\")\n+        if servers:\n+            _info(f\"Available servers: {', '.join(servers)}\")\n+        return\n+\n+    _reauth_oauth_server(name, servers[name])\n+\n+\n+def cmd_mcp_reauth(args):\n+    \"\"\"Re-authenticate one OAuth MCP server, or all of them sequentially.\n+\n+    ``hermes mcp reauth <name>`` re-auths a single server (same as ``login``).\n+    ``hermes mcp reauth --all`` discovers every ``auth: oauth`` server in\n+    config and re-auths them ONE AT A TIME.\n+\n+    Serial-by-design: a human can only complete one browser OAuth flow at a\n+    time, so re-authing all servers concurrently would open N tabs at once\n+    and N-1 would time out. This is the self-service fix for the recurring\n+    stale-client ritual in GH#36767 (and avoids the startup popup storm when\n+    several servers go stale at once).\n+    \"\"\"\n+    servers = _get_mcp_servers()\n+    do_all = getattr(args, \"all\", False)\n+    name = getattr(args, \"name\", None)\n+\n+    if do_all:\n+        oauth_servers = [\n+            (n, c) for n, c in servers.items()\n+            if c.get(\"auth\") == \"oauth\" and c.get(\"url\")\n+        ]\n+        if not oauth_servers:\n+            _info(\"No OAuth-based MCP servers found in config.\")\n+            return\n+        print()\n+        _info(f\"Re-authenticating {len(oauth_servers)} OAuth server(s) one at a time...\")\n+        succeeded = 0\n+        for n, c in oauth_servers:\n+            print()\n+            print(color(f\"  ── {n} ──\", Colors.CYAN + Colors.BOLD))\n+            if _reauth_oauth_server(n, c):\n+                succeeded += 1\n+        print()\n+        _success(f\"Re-authenticated {succeeded}/{len(oauth_servers)} server(s)\")\n+        return\n+\n+    if not name:\n+        _error(\"Specify a server name, or use --all to re-auth every OAuth server.\")\n+        _info(\"Usage: hermes mcp reauth <name>   |   hermes mcp reauth --all\")\n+        return\n+    if name not in servers:\n+        _error(f\"Server '{name}' not found in config.\")\n+        if servers:\n+            _info(f\"Available servers: {', '.join(servers)}\")\n+        return\n+\n+    _reauth_oauth_server(name, servers[name])\n \n \n # ─── hermes mcp configure ────────────────────────────────────────────────────","@@ -888,6 +949,7 @@ def mcp_command(args):\n         \"configure\": cmd_mcp_configure,\n         \"config\": cmd_mcp_configure,\n         \"login\": cmd_mcp_login,\n+        \"reauth\": cmd_mcp_reauth,\n     }\n \n     handler = handlers.get(action)","@@ -911,4 +973,5 @@ def mcp_command(args):\n         _info(\"hermes mcp test <name>                        Test connection\")\n         _info(\"hermes mcp configure <name>                   Toggle tools\")\n         _info(\"hermes mcp login <name>                       Re-authenticate OAuth\")\n+        _info(\"hermes mcp reauth <name> | --all              Re-auth one or all OAuth servers\")\n         print()"]},{"file":"hermes_cli/subcommands/mcp.py","hunks":["@@ -86,6 +86,19 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:\n     )\n     mcp_login_p.add_argument(\"name\", help=\"Server name to re-authenticate\")\n \n+    mcp_reauth_p = mcp_sub.add_parser(\n+        \"reauth\",\n+        help=\"Re-authenticate one OAuth MCP server, or all of them (--all)\",\n+    )\n+    mcp_reauth_p.add_argument(\n+        \"name\", nargs=\"?\", help=\"Server name to re-authenticate (omit with --all)\"\n+    )\n+    mcp_reauth_p.add_argument(\n+        \"--all\",\n+        action=\"store_true\",\n+        help=\"Re-authenticate every OAuth server in config, one at a time\",\n+    )\n+\n     # ── Catalog (Nous-approved MCPs shipped with the repo) ─────────────────\n     mcp_sub.add_parser(\n         \"picker\","]},{"file":"tests/hermes_cli/test_mcp_config.py","hunks":["@@ -754,3 +754,98 @@ def mock_probe(name, cfg):\n \n         assert \"Authenticated — 3 tool(s) available\" in out\n         assert \"no OAuth token\" not in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: cmd_mcp_reauth (GH#36767)\n+# ---------------------------------------------------------------------------\n+\n+class TestMcpReauth:\n+    def test_reauth_all_visits_only_oauth_servers_in_order(\n+        self, tmp_path, capsys, monkeypatch\n+    ):\n+        \"\"\"--all re-auths every oauth server (skipping non-oauth), serially.\"\"\"\n+        _seed_config(tmp_path, {\n+            \"gh\": {\"url\": \"https://gh.example.com/mcp\", \"auth\": \"oauth\"},\n+            \"jira\": {\"url\": \"https://jira.example.com/mcp\", \"auth\": \"oauth\"},\n+            \"localstdio\": {\"command\": \"foo\"},  # no url / no oauth → skipped\n+            \"apikey\": {\"url\": \"https://k.example.com/mcp\", \"headers\": {\"x\": \"y\"}},\n+        })\n+        visited = []\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._reauth_oauth_server\",\n+            lambda name, cfg: visited.append(name) or True,\n+        )\n+        from hermes_cli.mcp_config import cmd_mcp_reauth\n+\n+        cmd_mcp_reauth(_make_args(name=None, all=True))\n+        out = capsys.readouterr().out\n+\n+        assert visited == [\"gh\", \"jira\"]\n+        assert \"Re-authenticated 2/2 server(s)\" in out\n+\n+    def test_reauth_all_reports_partial_failures(self, tmp_path, capsys, monkeypatch):\n+        \"\"\"A server that fails to re-auth is counted but doesn't abort the rest.\"\"\"\n+        _seed_config(tmp_path, {\n+            \"a\": {\"url\": \"https://a.example.com/mcp\", \"auth\": \"oauth\"},\n+            \"b\": {\"url\": \"https://b.example.com/mcp\", \"auth\": \"oauth\"},\n+        })\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._reauth_oauth_server\",\n+            lambda name, cfg: name == \"a\",  # only 'a' succeeds\n+        )\n+        from hermes_cli.mcp_config import cmd_mcp_reauth\n+\n+        cmd_mcp_reauth(_make_args(name=None, all=True))\n+        out = capsys.readouterr().out\n+\n+        assert \"Re-authenticated 1/2 server(s)\" in out\n+\n+    def test_reauth_all_no_oauth_servers(self, tmp_path, capsys, monkeypatch):\n+        _seed_config(tmp_path, {\"localstdio\": {\"command\": \"foo\"}})\n+        called = []\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._reauth_oauth_server\",\n+            lambda name, cfg: called.append(name) or True,\n+        )\n+        from hermes_cli.mcp_config import cmd_mcp_reauth\n+\n+        cmd_mcp_reauth(_make_args(name=None, all=True))\n+        out = capsys.readouterr().out\n+\n+        assert \"No OAuth-based MCP servers found\" in out\n+        assert called == []\n+\n+    def test_reauth_single_server(self, tmp_path, capsys, monkeypatch):\n+        _seed_config(tmp_path, {\n+            \"gh\": {\"url\": \"https://gh.example.com/mcp\", \"auth\": \"oauth\"},\n+        })\n+        visited = []\n+        monkeypatch.setattr(\n+            \"hermes_cli.mcp_config._reauth_oauth_server\",\n+            lambda name, cfg: visited.append(name) or True,\n+        )\n+        from hermes_cli.mcp_config import cmd_mcp_reauth\n+\n+        cmd_mcp_reauth(_make_args(name=\"gh\", all=False))\n+        assert visited == [\"gh\"]\n+\n+    def test_reauth_requires_name_or_all(self, tmp_path, capsys):\n+        _seed_config(tmp_path, {\n+            \"gh\": {\"url\": \"https://gh.example.com/mcp\", \"auth\": \"oauth\"},\n+        })\n+        from hermes_cli.mcp_config import cmd_mcp_reauth\n+\n+        cmd_mcp_reauth(_make_args(name=None, all=False))\n+        out = capsys.readouterr().out\n+        assert \"Specify a server name\" in out\n+\n+    def test_reauth_unknown_server(self, tmp_path, capsys):\n+        _seed_config(tmp_path, {\n+            \"gh\": {\"url\": \"https://gh.example.com/mcp\", \"auth\": \"oauth\"},\n+        })\n+        from hermes_cli.mcp_config import cmd_mcp_reauth\n+\n+        cmd_mcp_reauth(_make_args(name=\"ghost\", all=False))\n+        out = capsys.readouterr().out\n+        assert \"not found\" in out"]},{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -827,3 +827,34 @@ async def instant_sleep(_):\n                 asyncio.run(_wait_for_callback())\n         err = capsys.readouterr().err\n         assert \"skip\" in err.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# poison_client_registration (GH#36767)\n+# ---------------------------------------------------------------------------\n+\n+class TestPoisonClientRegistration:\n+    def test_poison_backs_up_and_removes_client_and_meta(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        storage = HermesTokenStorage(\"srv\")\n+        d = tmp_path / \"mcp-tokens\"\n+        d.mkdir(parents=True)\n+        (d / \"srv.json\").write_text('{\"access_token\": \"[redacted:secret-assignment]\"}')\n+        (d / \"srv.client.json\").write_text('{\"client_id\": \"dead\"}')\n+        (d / \"srv.meta.json\").write_text('{\"token_endpoint\": \"[redacted:secret-assignment]\"}')\n+\n+        removed = storage.poison_client_registration()\n+\n+        assert removed is True\n+        # Client + metadata gone, forcing re-registration on the next flow.\n+        assert not (d / \"srv.client.json\").exists()\n+        assert not (d / \"srv.meta.json\").exists()\n+        # Backup of the client file kept for recovery.\n+        assert (d / \"srv.client.json.bak\").read_text() == '{\"client_id\": \"dead\"}'\n+        # Tokens are intentionally preserved.\n+        assert (d / \"srv.json\").read_text() == '{\"access_token\": \"[redacted:secret-assignment]\"}'\n+\n+    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"]},{"file":"tests/tools/test_mcp_oauth_manager.py","hunks":["@@ -164,3 +164,196 @@ def test_manager_fails_fast_noninteractive_without_cached_tokens(tmp_path, monke\n         mgr.get_or_build_provider(\"linear\", \"https://mcp.linear.app/mcp\", None)\n \n     assert mgr._entries[\"linear\"].provider is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# invalid_client auto-heal (GH#36767) — _maybe_flag_poisoned_client\n+# ---------------------------------------------------------------------------\n+\n+import asyncio\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock\n+\n+\n+def _fake_response(status, url, body):\n+    \"\"\"A minimal stand-in for the httpx.Response the SDK feeds our bridge.\"\"\"\n+    resp = MagicMock()\n+    resp.status_code = status\n+    resp.request = SimpleNamespace(url=url)\n+\n+    async def _aread():\n+        return body\n+\n+    resp.aread = _aread\n+    return resp\n+\n+\n+def _provider_with_token_endpoint(tmp_path, oauth_config, token_endpoint, monkeypatch):\n+    from tools.mcp_oauth_manager import MCPOAuthManager, reset_manager_for_tests\n+    reset_manager_for_tests()\n+    # Provider construction fails fast in a non-interactive environment with no\n+    # cached tokens (mcp_oauth_manager.py guard). The hermetic test env has no\n+    # TTY, so present an interactive stdin to reach the code under test.\n+    _set_interactive_stdin(monkeypatch)\n+    mgr = MCPOAuthManager()\n+    provider = mgr.get_or_build_provider(\"srv\", \"https://mcp.example.com\", oauth_config)\n+    provider.context.oauth_metadata = SimpleNamespace(token_endpoint=[redacted:secret-assignment]\n+    provider._initialized = True\n+    return provider\n+\n+\n+def test_invalid_client_at_token_endpoint_poisons(tmp_path, monkeypatch):\n+    \"\"\"400 invalid_client on the token endpoint deletes the dead client.json.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    d = tmp_path / \"mcp-tokens\"\n+    d.mkdir(parents=True)\n+    (d / \"srv.client.json\").write_text('{\"client_id\": \"dead\"}')\n+    (d / \"srv.meta.json\").write_text(\"{}\")\n+    provider = _provider_with_token_endpoint(\n+        tmp_path, {}, \"https://idp.example.com/oauth/token\", monkeypatch\n+    )\n+    resp = _fake_response(\n+        400, \"https://idp.example.com/oauth/token\", b'{\"error\":\"invalid_client\"}'\n+    )\n+\n+    asyncio.run(provider._maybe_flag_poisoned_client(resp))\n+\n+    assert not (d / \"srv.client.json\").exists()\n+    assert (d / \"srv.client.json.bak\").exists()\n+    assert provider._initialized is False\n+    assert provider.context.client_info is None\n+\n+\n+def test_invalid_client_at_other_endpoint_is_ignored(tmp_path, monkeypatch):\n+    \"\"\"An invalid_client body from a non-token endpoint must not poison.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    d = tmp_path / \"mcp-tokens\"\n+    d.mkdir(parents=True)\n+    (d / \"srv.client.json\").write_text('{\"client_id\": \"live\"}')\n+    provider = _provider_with_token_endpoint(\n+        tmp_path, {}, \"https://idp.example.com/oauth/token\", monkeypatch\n+    )\n+    resp = _fake_response(\n+        400, \"https://mcp.example.com/messages\", b'{\"error\":\"invalid_client\"}'\n+    )\n+\n+    asyncio.run(provider._maybe_flag_poisoned_client(resp))\n+\n+    assert (d / \"srv.client.json\").exists()\n+    assert provider._initialized is True\n+\n+\n+def test_success_response_is_ignored(tmp_path, monkeypatch):\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    d = tmp_path / \"mcp-tokens\"\n+    d.mkdir(parents=True)\n+    (d / \"srv.client.json\").write_text('{\"client_id\": \"live\"}')\n+    provider = _provider_with_token_endpoint(\n+        tmp_path, {}, \"https://idp.example.com/oauth/token\", monkeypatch\n+    )\n+    resp = _fake_response(\n+        200, \"https://idp.example.com/oauth/token\", b'{\"access_token\":\"x\"}'\n+    )\n+\n+    asyncio.run(provider._maybe_flag_poisoned_client(resp))\n+\n+    assert (d / \"srv.client.json\").exists()\n+    assert provider._initialized is True\n+\n+\n+def test_preregistered_client_is_never_poisoned(tmp_path, monkeypatch):\n+    \"\"\"A config-supplied client_id is never auto-deleted (re-reg can't help).\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    provider = _provider_with_token_endpoint(\n+        tmp_path, {\"client_id\": \"from-config\"}, \"https://idp.example.com/oauth/token\", monkeypatch\n+    )\n+    d = tmp_path / \"mcp-tokens\"\n+    # _maybe_preregister_client wrote client.json from config during build.\n+    assert (d / \"srv.client.json\").exists()\n+    resp = _fake_response(\n+        400, \"https://idp.example.com/oauth/token\", b'{\"error\":\"invalid_client\"}'\n+    )\n+\n+    asyncio.run(provider._maybe_flag_poisoned_client(resp))\n+\n+    assert (d / \"srv.client.json\").exists()\n+    assert provider._initialized is True\n+\n+\n+def test_invalid_client_metadata_does_not_trip(tmp_path, monkeypatch):\n+    \"\"\"RFC 7591 `invalid_client_metadata` must NOT be mistaken for invalid_client.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    d = tmp_path / \"mcp-tokens\"\n+    d.mkdir(parents=True)\n+    (d / \"srv.client.json\").write_text('{\"client_id\": \"live\"}')\n+    provider = _provider_with_token_endpoint(\n+        tmp_path, {}, \"https://idp.example.com/oauth/token\", monkeypatch\n+    )\n+    resp = _fake_response(\n+        400, \"https://idp.example.com/oauth/token\", b'{\"error\":\"invalid_client_metadata\"}'\n+    )\n+\n+    asyncio.run(provider._maybe_flag_poisoned_client(resp))\n+\n+    assert (d / \"srv.client.json\").exists()\n+    assert provider._initialized is True\n+\n+\n+class _FakeMeta:\n+    \"\"\"Metadata stub usable by both detection and the post-flow persist hook.\"\"\"\n+\n+    def __init__(self, token_endpoint):\n+        self.token_endpoint = [redacted:secret-assignment]\n+\n+    def model_dump(self, **kwargs):\n+        return {\"token_endpoint\": self.token_endpoint}\n+\n+\n+def test_bridge_forwards_requests_and_poisons_on_token_endpoint_400(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"Drive the REAL async_auth_flow bridge to prove the inserted detection\n+    hook does not break the bidirectional asend() forwarding contract — the\n+    genuinely fragile part. A patched SDK base generator stands in for the\n+    real OAuth flow so we control exactly which response the bridge sees.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    token_ep = \"[redacted:secret-assignment]\"\n+    d = tmp_path / \"mcp-tokens\"\n+    d.mkdir(parents=True)\n+    (d / \"srv.client.json\").write_text('{\"client_id\": \"dead\"}')\n+\n+    forwarded = []\n+\n+    async def fake_base_flow(self, request):\n+        # Mimic the SDK: yield the request, receive the response, then finish.\n+        forwarded.append((\"out\", request))\n+        response = yield request\n+        forwarded.append((\"in\", response))\n+\n+    from mcp.client.auth.oauth2 import OAuthClientProvider\n+    monkeypatch.setattr(OAuthClientProvider, \"async_auth_flow\", fake_base_flow)\n+\n+    provider = _provider_with_token_endpoint(tmp_path, {}, token_ep, monkeypatch)\n+    provider.context.oauth_metadata = _FakeMeta(token_ep)\n+\n+    sentinel_request = object()\n+    poison_resp = _fake_response(400, token_ep, b'{\"error\":\"invalid_client\"}')\n+\n+    async def drive():\n+        gen = provider.async_auth_flow(sentinel_request)\n+        out0 = await gen.__anext__()\n+        assert out0 is sentinel_request  # request forwarded unchanged\n+        try:\n+            await gen.asend(poison_resp)\n+        except StopAsyncIteration:\n+            pass\n+\n+    asyncio.run(drive())\n+\n+    # The poison response reached the inner generator (forwarding intact)...\n+    assert (\"in\", poison_resp) in forwarded\n+    # ...and the detection hook fired.\n+    assert not (d / \"srv.client.json\").exists()\n+    assert provider._initialized is False\n+    assert provider.context.client_info is None"]},{"file":"tools/mcp_oauth.py","hunks":["@@ -343,6 +343,41 @@ def remove(self) -> None:\n         for p in (self._tokens_path(), self._client_info_path(), self._meta_path()):\n             p.unlink(missing_ok=True)\n \n+    def poison_client_registration(self) -> bool:\n+        \"\"\"Discard a dead dynamically-registered client so it gets re-created.\n+\n+        Called when the IdP rejects our cached ``client_id`` with\n+        ``invalid_client`` on the token endpoint — proof the server-side\n+        registration is gone (IdP redeploy / DB wipe / rebrand). Deleting\n+        ``client.json`` makes the MCP SDK's ``async_auth_flow`` take the\n+        ``if not client_info`` branch and re-run RFC 7591 dynamic client\n+        registration on the next flow. The stale ``meta.json`` is dropped\n+        too so discovery re-runs against a freshly fetched document.\n+\n+        Tokens are intentionally left in place — the subsequent\n+        re-authorization overwrites them, and keeping them avoids losing a\n+        still-valid refresh token if the re-registration never completes.\n+\n+        A single ``.bak`` copy of the client file is kept for recovery.\n+        Returns True if a client file was present and removed.\n+        \"\"\"\n+        client_path = self._client_info_path()\n+        if not client_path.exists():\n+            return False\n+        backup = client_path.with_name(client_path.name + \".bak\")\n+        try:\n+            backup.write_bytes(client_path.read_bytes())\n+        except OSError as exc:  # non-fatal — proceed with the removal anyway\n+            logger.warning(\"Could not back up client info at %s: %s\", client_path, exc)\n+        client_path.unlink(missing_ok=True)\n+        self._meta_path().unlink(missing_ok=True)\n+        logger.warning(\n+            \"MCP OAuth '%s': cached client registration rejected as invalid_client; \"\n+            \"removed client.json + meta.json (backup at %s) to force re-registration\",\n+            self._server_name, backup.name,\n+        )\n+        return True\n+\n     def has_cached_tokens(self) -> bool:\n         \"\"\"Return True if we have tokens on disk (may be expired).\"\"\"\n         return self._tokens_path().exists()"]},{"file":"tools/mcp_oauth_manager.py","hunks":["@@ -36,6 +36,7 @@\n \n import asyncio\n import logging\n+import re\n import threading\n from dataclasses import dataclass, field\n from typing import Any, Optional","@@ -43,6 +44,26 @@\n logger = logging.getLogger(__name__)\n \n \n+def _same_endpoint(a: str, b: str) -> bool:\n+    \"\"\"Return True if two URLs target the same endpoint (ignoring query/fragment).\n+\n+    Compares scheme, host (case-insensitive), and path. Used to confirm a\n+    rejected response actually came from the OAuth token endpoint before we\n+    act on an ``invalid_client`` body.\n+    \"\"\"\n+    from urllib.parse import urlsplit\n+\n+    try:\n+        pa, pb = urlsplit(a), urlsplit(b)\n+    except ValueError:  # pragma: no cover — malformed URL\n+        return False\n+    return (\n+        pa.scheme == pb.scheme\n+        and pa.netloc.lower() == pb.netloc.lower()\n+        and pa.path.rstrip(\"/\") == pb.path.rstrip(\"/\")\n+    )\n+\n+\n # ---------------------------------------------------------------------------\n # Per-server entry\n # ---------------------------------------------------------------------------","@@ -107,9 +128,21 @@ class HermesMCPOAuthProvider(OAuthClientProvider):\n         (``src/utils/auth.ts:1320``, CC-1096 / GH#24317).\n         \"\"\"\n \n-        def __init__(self, *args: Any, server_name: str = \"\", **kwargs: Any):\n+        def __init__(\n+            self,\n+            *args: Any,\n+            server_name: str = \"\",\n+            preregistered: bool = False,\n+            **kwargs: Any,\n+        ):\n             super().__init__(*args, **kwargs)\n             self._hermes_server_name = server_name\n+            # When the client_id comes from config.yaml (pre-registered), an\n+            # invalid_client rejection means the *config* is wrong — deleting\n+            # client.json would just be re-seeded from config and re-running\n+            # registration can't help. Only auto-heal dynamically-registered\n+            # clients. See _maybe_flag_poisoned_client.\n+            self._hermes_preregistered = preregistered\n \n         async def _initialize(self) -> None:\n             \"\"\"Load stored tokens + client info AND seed token_expiry_time.","@@ -284,6 +317,74 @@ def _persist_oauth_metadata_if_changed(self) -> None:\n             ):\n                 storage.save_oauth_metadata(meta)\n \n+        async def _maybe_flag_poisoned_client(self, response: Any) -> None:\n+            \"\"\"Detect a dead client registration and force re-registration.\n+\n+            When the IdP rejects our ``client_id`` with ``invalid_client`` on\n+            the token endpoint (token exchange or refresh), the cached client\n+            registration is provably dead server-side. We delete ``client.json``\n+            (+ stale metadata) so the SDK's next ``async_auth_flow`` takes the\n+            ``if not client_info`` branch and re-runs RFC 7591 dynamic client\n+            registration. This addresses the recurring manual-reset ritual in\n+            GH#36767 for the auto-detectable subset (token-endpoint rejection);\n+            the browser-side \"Redirect URI Mismatch\" case has no HTTP signal\n+            and is handled by ``hermes mcp reauth``.\n+\n+            Conservative by construction — acts ONLY when all hold:\n+              * status is 400/401,\n+              * the request hit the discovered ``token_endpoint`` (the only\n+                request carrying our ``client_id``), and\n+              * the body carries the ``invalid_client`` error code\n+                (word-boundary match, so RFC 7591's ``invalid_client_metadata``\n+                registration error does not trip it).\n+            Pre-registered (config-supplied) clients are never poisoned.\n+            Fully best-effort: any failure here is swallowed so a detection\n+            miss never breaks the live auth flow.\n+\n+            Covers both the authorization-code token exchange and the\n+            preemptive refresh — but only when ``token_endpoint`` was\n+            discovered (``_initialize`` prefetches it on cold-load). If that\n+            discovery was skipped, the guard returns early and the user falls\n+            back to ``hermes mcp reauth``.\n+            \"\"\"\n+            try:\n+                if self._hermes_preregistered:\n+                    return\n+                status = getattr(response, \"status_code\", None)\n+                if status not in (400, 401):\n+                    return\n+                meta = getattr(self.context, \"oauth_metadata\", None)\n+                token_endpoint = (\n+                    str(meta.token_endpoint)\n+                    if meta is not None and getattr(meta, \"token_endpoint\", None)\n+                    else None\n+                )\n+                req = getattr(response, \"request\", None)\n+                req_url = str(req.url) if req is not None else None\n+                if not token_endpoint or not req_url:\n+                    return\n+                if not _same_endpoint(req_url, token_endpoint):\n+                    return\n+                body = await response.aread()\n+                # Word-boundary match: matches `\"error\":\"invalid_client\"` but\n+                # not the RFC 7591 registration error `invalid_client_metadata`\n+                # (the trailing `_metadata` removes the right-hand boundary).\n+                if not re.search(rb\"\\binvalid_client\\b\", body.lower()):\n+                    return\n+\n+                storage = self.context.storage\n+                from tools.mcp_oauth import HermesTokenStorage\n+                if isinstance(storage, HermesTokenStorage):\n+                    storage.poison_client_registration()\n+                # Drop the in-memory client so the SDK re-registers next flow.\n+                self.context.client_info = None\n+                self._initialized = False\n+            except Exception as exc:  # pragma: no cover — defensive, must not throw\n+                logger.debug(\n+                    \"MCP OAuth '%s': invalid_client detection failed (non-fatal): %s\",\n+                    self._hermes_server_name, exc,\n+                )\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","@@ -317,6 +418,9 @@ async def async_auth_flow(self, request):  # type: ignore[override]\n                 outgoing = await inner.__anext__()\n                 while True:\n                     incoming = yield outgoing\n+                    # Sniff the response for a dead-client-registration signal\n+                    # before handing it back to the SDK (best-effort, GH#36767).\n+                    await self._maybe_flag_poisoned_client(incoming)\n                     outgoing = await inner.asend(incoming)\n             except StopAsyncIteration:\n                 # Persist any metadata the SDK discovered lazily during the","@@ -439,6 +543,7 @@ def _build_provider(\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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}