fix(oauth): reuse persisted DCR client instead of re-registering on every connect#80
Conversation
…very connect MCPOAuthProvider.ensureClientRegistration() gated registration only on the in-memory this.config.clientId. That field is seeded solely from options.clientId / WP_OAUTH_CLIENT_ID at construction and is never read back from disk, so any fresh process without an env client ID minted a brand-new RFC 7591 client and overwrote the persisted client_info.json — orphaning the prior registration server-side. When the browser flow was never completed (the common re-prompt case) this left a tokenless client row, matching the ~100%-orphan fingerprint observed server-side. Read the persisted client_info.json for this serverUrlHash before calling the DCR /register endpoint and reuse a stored client_id when present, so registration runs only when no client has ever been registered for the server. readClientInfo was already imported but unused. Tests cover: stored client_id -> no /register call; no stored client -> exactly one /register call with the result persisted; absent/expired tokens still reuse (re-auth, not re-register); explicit client_id skips both paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes repeated OAuth Dynamic Client Registration (DCR) by making MCPOAuthProvider.ensureClientRegistration() reuse a previously persisted client_info.json entry (keyed by serverUrlHash) instead of registering a new client on each fresh process run, reducing server-side orphaned DCR records and unnecessary /register calls.
Changes:
- Load persisted
client_idviareadClientInfo(serverUrlHash)and short-circuit DCR when present. - Add unit tests to verify reuse-first behavior, correct persistence on first registration, and explicit
clientIdbypass behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/lib/mcp-oauth-provider.ts |
Reuses a persisted client_id before attempting dynamic registration. |
tests/unit/mcp-oauth-provider.test.ts |
Adds regression tests ensuring reuse-first behavior and correct registration/persistence semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
galatanovidiu
left a comment
There was a problem hiding this comment.
Solid, well-scoped fix. The reuse-first logic correctly stops the per-connect re-registration, security is clean for a public client (token_endpoint_auth_method: 'none', so restoring only client_id is right), and it follows the repo's test conventions. Full suite green, 212/212.
One thing worth resolving before this becomes load-bearing: there is no self-healing for a stale persisted client_id. After this change, a client_id the authorization server later rejects is reused on every run and never re-registered. invalidateCredentials('client') already exists but has zero callers in src/, and invalid_client is classified nowhere, so no error path clears client_info.json at runtime. The only recovery is manually deleting the file — which is exactly the state the server-side orphan cleanup that motivates this PR will produce for tokenless clients. A guarded single re-register on an invalid_client-class rejection would close the loop; if that is out of scope, listing it as a known limitation next to the deferred cross-process lock is enough.
Two smaller points inline. None of this blocks merge.
Separate observation (not introduced by this PR): the auth config dir is version-scoped (wordpress-remote-${VERSION}). On a release bump, a client_info.json written by the prior version is no longer found, so the provider re-registers and orphans the old client once per upgrade. The fix still cuts orphan creation from "every connect" to "once per version," but if cross-version reuse is intended, the client_info path should not include the package version. Worth a follow-up.
Address review feedback on the reuse-first DCR change: - Validate the persisted client_id with an explicit string check instead of a truthiness check, so a corrupt-but-JSON-valid client_info.json (non-string or empty client_id) falls through to registration rather than promoting a bad value into the authorization URL. - Document why the reuse read intentionally runs before the OAUTH_DYNAMIC_REGISTRATION guard (the flag governs minting a new client, not reusing one already obtained). - Strengthen tests: disableNetConnect so an interceptor mismatch fails fast instead of hitting the network; assert an exact /register call count rather than "at least once"; add a corrupt-client_info fall-through case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @galatanovidiu — all five addressed in 889b2e3: Fixed in this PR (low-risk hardening):
Deferred to follow-ups (documented in the PR description):
I'll open the follow-up for self-heal + version-independent |
Problem
The OAuth server has been accumulating large numbers of orphaned Dynamic Client Registration (RFC 7591) records, with a near-100% orphan rate (no owner, no matching token, authorization never completed). The pattern is one client identity re-registering a fresh DCR client on essentially every connect instead of reusing a prior registration.
Root cause
MCPOAuthProvideris the active DCR provider (selected for the OAuth 2.1authorization_code+ PKCE defaults inwordpress-api.ts). ItsensureClientRegistration()gated registration only on the in-memorythis.config.clientId:this.config.clientIdis seeded solely fromoptions.clientId/WP_OAUTH_CLIENT_IDat construction and is never read back from disk.readClientInfowas imported but never called. So any fresh process without an env client ID:/register, andclient_info.json, orphaning the prior registration server-side.If the browser flow is never completed (the common unexpected-re-prompt case), the result is a tokenless orphan. This happens on first run, on missing/expired tokens, and on every run for stateless/ephemeral hosts where the config dir doesn't persist.
Fix
Read the persisted
client_info.jsonfor thisserverUrlHashbefore hitting/register, and reuse a storedclient_idwhen present. Registration now runs only when no client has ever been registered for the server — making the path reuse-first.Tests
New
tests/unit/mcp-oauth-provider.test.ts(nock-based, matching the repo's existing pattern):client_idpresent → no/registercall, stored id reused/registercall, result persisted toclient_info.jsonclient_idprovided → skips both reuse and registrationFull suite green: 212/212.
Note on the client_name fingerprint
This provider registers with
client_name = "WordPress MCP Remote"(mcp-oauth-provider.ts:259). If orphaned records on the server show a differentclient_name, they originate from a different client identity (e.g. another component or an older release), not this code path — this PR fixes the bug pattern present here, which would otherwise keep producing"WordPress MCP Remote"orphans the same way. Any differently-named orphans should be confirmed separately.Deliberately deferred (follow-up)
Cross-process registration locking (concurrent starts → at most one registration). The MCP path currently has only an in-process
authPromiseguard; the lockfile coordinator (coordination.ts) is wired only into the legacy provider. Reuse-first eliminates the steady-state re-registration entirely and narrows the remaining race to a single cold start with no stored client. Wiring the lock into the MCP path is a larger change touching the coordinator and is kept out of this PR per the repo's commit-discipline rule (every line traces to one goal).Known limitations / follow-ups
These came out of review. None block the orphan fix this PR delivers; each is a separate, larger change:
client_id. After this change, a persistedclient_idthat the authorization server later rejects (invalid_client-class) is reused on every run and never re-registered — the only recovery is deletingclient_info.json. Closing the loop needsinvalid_clientclassification at token exchange (mcp-oauth-utils.tscurrently keys only on HTTP status) plus a guarded single re-register (invalidateCredentials('client')→ clearclientId→ re-runensureClientRegistration()), which is its own flow + test surface. Deferred to a follow-up alongside the cross-process lock.client_info.jsonlives underwordpress-remote-${VERSION}, so a release bump can't find the prior version's file and re-registers (orphaning the old client once per upgrade). This is pre-existing and not introduced here; this PR still cuts orphan creation from "every connect" to "once per version." If cross-version reuse is intended, the client_info path should drop the version segment — separate follow-up.