MCP OAuth: persist DCR client_id across reconnects (avoid AS-side registration spam)

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

When Cursor connects to a remote MCP server that uses OAuth 2.1 with Dynamic Client Registration (RFC 7591), it performs a fresh DCR call to the authorization server’s /oauth/register endpoint on every reconnect or token refresh, instead of caching and reusing the client_id from the first registration. This floods the AS with duplicate “Cursor” client entries that all have identical redirect_uris and scopes — they are functionally indistinguishable. AS dashboards have no built-in cleanup mechanism (Clerk’s docs explicitly warn about the resulting administrative overhead), so resource server operators have to write cron jobs to delete stale clients.

Steps to Reproduce

  1. Add a remote MCP server in Cursor settings whose URL exposes /.well-known/oauth-protected-resource pointing to an AS with DCR enabled (I tested with Clerk).
  2. Click Connect — full OAuth 2.1 + PKCE flow runs, MCP works, you get a token.
  3. Disconnect the MCP server in Cursor settings, then reconnect.
  4. Query the AS’s OAuth Applications list (e.g. Clerk Dashboard or GET https://api.clerk.com/v1/oauth_applications).

Each reconnect creates a new entry. From a single Cursor instance against my server I logged 5 entries within 9 minutes in one session, and 3 more within 4 minutes after a fresh reconnect later. All entries: same name “Cursor”, same redirect_uri “cursor://anysphere.cursor-mcp/oauth/callback”, same scopes, same client_uri.

Expected Behavior

Cursor caches the client_id (and refresh_token where applicable) from the first DCR registration, scoped per (MCP server URL, workspace). On subsequent connections, Cursor reuses the cached client_id and only triggers /oauth/register again if the AS rejects it (e.g. invalid_client at /oauth/token). RFC 7591 §3 explicitly permits clients to persist DCR registrations for reuse.

Operating System

Windows 10/11

Version Information

Version: 3.1.15 (user setup)
VSCode Version: 1.105.1
Commit: 3a67af7b780eObfc8d32aefa96b8ffıcb8817f80
Date: (1 day ago)
Layout: editor
Build Type: Stable
Release Track: Default
Electron: 39.8.1
Chromium: 142.0.7444.265
Nodejs: 22.22.1
V8: 14.2.231.22-electron.0
OS: Windows NT x64 10.0.19045

Additional Information

Inspecting one of the registered clients via the Clerk admin API:

{
“name”: “Cursor”,
“client_id”: “MqJwPUkigF2osFOS”,
“redirect_uris”: [“cursor://anysphere.cursor-mcp/oauth/callback”],
“scopes”: “email offline_access profile”,
“dynamically_registered”: true,
“public”: true,
“created_at”: 1776369856047
}

Three other entries from the same Cursor instance within the same hour have identical fields except client_id and created_at. There’s no observable reason these should be separate clients.

Side note: Cursor correctly reads scopes_supported from the resource server’s RFC 9728 protected-resource metadata and includes those scopes in the /oauth/authorize call. That part works well — it’s only the DCR persistence that’s missing.

Bug report URL of corresponding Clerk Support ticket I’m filing (for cleanup automation): can be cross-referenced if needed.

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey, great bug report, and thanks for the details.

This is a known issue. DCR persistence in Cursor is implemented, but there are a few edge-case paths that can force a re-registration instead of reusing the cached client_id, which is why you see duplicates on the AS side.

Over the last few weeks we’ve landed several solid fixes in this area (race conditions around invalidation credentials, contention when multiple clients are involved, and more), but some of them might not be in 3.1.15 yet. Please try updating to the latest version, and you might see the rate of re-registrations drop a lot.

I’ve passed this to the team to take a closer look. No exact timeline yet, but your report helps with prioritization, especially the context about AS-side cleanup, that’s a useful data point.

Let me know if anything changes after you update.

Hey @deaxu!

Please try again in v3.2 and let us know if it’s working better now.

Hey @Colin — gave v3.2 a proper test run against an MCP server backed by Clerk OAuth (DCR). Sharing what we found in case it helps.

The fix definitely works. Across Disable/Enable cycles, window reloads and full Cursor restarts, the logs show Returning stored OAuth client_id every time after the first auth — no re-registration spam. Thanks for getting it shipped :folded_hands:

One thing we’re not sure if it’s a leftover bug or our setup: every fresh OAuth flow mints two DCR clients on the AS side (not one). Reproduced cleanly across 4 logins, always exactly 2 entries ~4 seconds apart:

2026-05-01 00:37:12 — Cursor (oa_3D5w2d…)

2026-05-01 00:37:16 — Cursor (oa_3D5w3D…)

2026-05-01 00:38:28 — Cursor (oa_3D5wCE…)

2026-05-01 00:38:32 — Cursor (oa_3D5wCk…)

Peeking at Cursor’s local SQLite (%APPDATA%\Cursor\User\globalStorage\state.vscdb), the DCR client info gets stored under two parallel cache keys for the same MCP entry:

secret://…[url:aHR0cHM6Ly9tY3AuYXJjaGdyYXBoLmRldi9tY3A] mcp_client_information

secret://…[user-archgraph] mcp_client_information

(first is base64 of the server URL, second is the entry name from mcp.json).

Looks like each cache key dedupes correctly within itself (reconnects reuse — no growth there ✓), but the two keys don’t share state, so a single fresh consent doubles entries on the AS.

Could be on our end though. We followed Clerk’s MCP guide (Build a Clerk-authorized MCP server and the @clerk/mcp-tools README) — our PRM advertises authorization_servers: [<clerk-frontend-api>], scopes email profile offline_access, registered redirect URIs are the standard Cursor trio (cursor://anysphere.cursor-mcp/oauth/callback, https://www.cursor.com/agents/mcp/oauth/callback, http://localhost:8787/callback). Nothing custom on the resource-server side that we noticed. If there’s some setting we’ve missed that would route both cache keys to the same registration, very happy to know.

Tiny UX note unrelated to the above: the v3.2 mediated callback at https://www.cursor.com/agents/mcp/oauth/callback leaves the browser tab idle after Allow (no auto-close — makes sense since the tab wasn’t opened by JS). A small “Connected, you can close this tab” placeholder would smooth that over vs. the older cursor:// custom-scheme flow.

Happy to dig deeper on any of this if useful — full reproduction is just mcp.json → Allow → check the AS apps list.

Hey @deaxu, thanks for the super thorough follow-up. Confirming that within-key dedupe works in v3.2 is great news.

On the two observations:

  1. Double registration two cache keys for one entry. Your debugging is spot on. The parallel keys [url:<base64>] and [<mcp.json entry name>] really do look like both code paths write client info into SecretStorage independently and don’t share state, so a fresh consent ends up triggering exactly two DCR registrations. This isn’t on your side. Your config matches the Clerk guide, and there’s nothing unusual in PRM. I’ve reported this as a separate bug to the team, including your cache keys and repro steps. I can’t share an ETA yet, but I’ll update here if anything changes.

  2. Browser tab UX after Allow. This is already on our radar. In v3.2 the mediated flow through https://www.cursor.com/agents/mcp/oauth/callback doesn’t show a success page or auto-close, so the browser tab stays in loading or idle. I’ll add your note to the existing ticket as another signal.

If you dig deeper and find more artifacts, like which code path writes the [user-<entry>] key, I’d really appreciate any extra details.

I dug into `extensions/cursor-mcp/dist/main.js` since the bundle is shipped readable.

**SQLite-side observation:** the `[] mcp_client_information` key isn’t a parallel write path — it’s the legacy fallback shadow. The provider has two getters:

```

sharedClientInformationKey => [url:<base64url(canonicalServerUrl)>] mcp_client_information

legacyClientInformationKey => [] mcp_client_information // identifier = user- or project--

```

…and `storeSecretWithLegacyFallback(shared, legacy, value)` writes to both unconditionally inside `saveClientInformation` and `applyHydratedEntry`. So two SQLite rows from one save is by design; both rows point at the same ClientInformation blob. That part is fine.

**Server-side dup is the real question.** `_acquireRegistrationLockOrWait` keys on `mcp_oauth_registration_lock::` via `diskKVSetIfAbsent`, which should serialize concurrent providers for the same server URL. But the function early-returns without holding any lock if `oauthContext.diskKVSetIfAbsent` is undefined:

```

async _acquireRegistrationLockOrWait() {

const e = this.oauthContext.diskKVSetIfAbsent;

if (!e) return; // ← silent no-op when disk-KV isn’t wired

}

```

If two providers spawn concurrently for the same MCP entry (e.g. one from each `mcp.json` source) and disk-KV isn’t available in that context, both POST to `/register` ~one round-trip apart — matches the ~4s gap I see. Worth checking whether `diskKVSetIfAbsent` is wired on every code path that constructs a provider, or whether the lock should be unconditional.

Happy to share more if useful.