Skip to content

Re-read OAuth state from disk across MCP sessions - #848

Merged
TheGreatAxios merged 5 commits into
mainfrom
cl-7527-fix-cross-session-oauth-token-staleness-in-mcp-provider
Sep 10, 2026
Merged

Re-read OAuth state from disk across MCP sessions#848
TheGreatAxios merged 5 commits into
mainfrom
cl-7527-fix-cross-session-oauth-token-staleness-in-mcp-provider

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Summary

  • OAuth provider sync getters detect auth-file changes and reload shared state from disk
  • Fresh tokens saved by one session become available to existing sibling sessions
  • Failed or unreadable disk refreshes preserve the in-memory mirror and retry later

Verification

  • bun run check passes (6,286 tests, 0 failures)
  • Targeted OAuth provider tests pass (10 tests, 0 failures)

Fixes CL-7527

The MCP OAuth provider snapshotted auth state once at construction, so
a session that re-authenticated never published its fresh tokens to
sibling sessions: they kept serving a stale in-memory mirror and
needlessly triggered the browser flow after a 401.

The SDK requires tokens(), clientInformation(), and codeVerifier() to
be synchronous, so the getters now guard with statSync and only
readFileSync the auth file (via a new loadAuthStateSync that mirrors
loadAuthState's parsing) when its mtime or size changed. A vanished or
unreadable file keeps the in-memory mirror instead of throwing from a
sync getter.
The refresh guard recorded the file stamp before reading it, so a
persistently unreadable auth file (e.g. chmod 000) wiped the in-memory
mirror with empty state and never retried. The stamp is now committed
only after a successful read, leaving it stale so the next getter call
retries. loadAuthStateSync also now matches loadAuthState's error
contract: missing (ENOENT) or corrupt files yield empty state, while
other read errors propagate to the caller instead of being swallowed.
@linear-code

linear-code Bot commented Sep 9, 2026

Copy link
Copy Markdown

CL-7527

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primary · Comment

Ready for human review. The branch stays within the OAuth provider/store boundary, preserves synchronous SDK getter contracts, and passes the repository pre-PR gate.

No residual findings.

Checks: bun run check (exit 0; 6,286 pass, 0 fail); reviewer-of-record log/stat/message audits; targeted OAuth tests (exit 0).

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critic · Comment

The full origin/main...HEAD behavioral review is clean. Existing sibling providers observe newly saved tokens while missing or unreadable auth files preserve the in-memory mirror.

No findings.

A different-port sibling writing PKCE or DCR was replacing this session's live verifier and redirect. Failed disk reads also cleared good in-memory tokens because empty-on-corrupt was used as cache invalidation.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes.

CL-7527 is only half-met at a41fda5. Idle-sibling token reload and PKCE locality hold; DCR locality does not survive the completion mutation.

shouldAdoptClient is used only in refreshDurableFromDisk. apply() still unconditionally assignClient (src/mcp/oauth-provider.ts), and saveTokens / saveCodeVerifier both go through apply.

Trigger: Session A saveClient + saveCodeVerifier on port 62000, then sibling B constructs on 60435 during A's browser wait. B createOAuthProvider runs dropStaleClientRegistration and writes the file with no client. A's getters still hold client-on-62000. User finishes A's flow; SDK saveTokens. apply loads B's snapshot, persists tokens with no client, then assignClient deletes A's in-memory DCR. Next clientInformation() is undefined, so another browser flow. If B saveClients first, A's tokens land under B's client_id.

The getter-only tests never call apply on A after B writes, so they miss this.

Please apply the same adopt-client guard on apply (or keep this session matching client when the disk snapshot has none / a different-port client without completed tokens), and add a test that A saveTokens after B construct still keeps A's client_id / redirect_uris.

Should-fix: HEAD dropped the mtime/size stamp, so every SDK tokens() readFileSyncs. MCP StreamableHTTPClientTransport._commonHeaders calls tokens() on every send().

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes.

CL-7527 is only half-met at a41fda5c. Idle-sibling token reload and PKCE locality hold; DCR locality does not survive the completion mutation.

shouldAdoptClient is used only in refreshDurableFromDisk. apply() still unconditionally assignClient (src/mcp/oauth-provider.ts), and saveTokens / saveCodeVerifier both go through apply.

Trigger: Session A saveClient + saveCodeVerifier on port 62000, then sibling B constructs on 60435 during A's browser wait. B createOAuthProvider runs dropStaleClientRegistration and writes the file with no client. A's getters still hold the 62000 client. User finishes A's flow; SDK saveTokens. apply loads B's snapshot, persists tokens with no client, then assignClient deletes A's in-memory DCR. Next clientInformation() is undefined, so another browser flow. If B saveClients first, A's tokens land under B's client_id.

The getter-only tests never call apply on A after B writes, so they miss this.

Please apply the same adopt-client guard on apply (or keep this session matching client when the disk snapshot has none / a different-port client without completed tokens), and add a test that A saveTokens after B construct still keeps A's client_id / redirect_uris.

Should-fix: HEAD dropped the mtime/size stamp, so every SDK tokens() hits readFileSync. MCP StreamableHTTPClientTransport._commonHeaders calls tokens() on every send().

apply() assigned disk client after every mutation, so a different-port
sibling saveClient then this session's saveTokens replaced live DCR.
Mutations now rewrite this session's matching client onto disk; getters
still adopt completed sibling tokens.
Idle sibling tokens must still land on a session that already has matching live DCR, without going through apply. Same-port saveClient must replace this session's client even after a sibling wrote a different port.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve (GitHub cannot approve own PR).

CL-7527 holds at 0c8cc4de. Sync SDK getters re-read auth state from disk when the file stamp changes; apply() keeps matching live DCR across sibling saveTokens; idle sibling tokens still adopt without going through apply.

CI is green (prettier, eslint, typecheck, build-and-test).

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve (GitHub cannot approve own PR).

CL-7527 holds at 0c8cc4d. Sync SDK getters re-read auth state from disk when the file stamp changes; apply() keeps matching live DCR across sibling saveTokens; idle sibling tokens still adopt without going through apply.

CI is green (prettier, eslint, typecheck, build-and-test).

@TheGreatAxios
TheGreatAxios merged commit 474e44e into main Sep 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant