Repository navigation
Conversation
🦋 Changeset detectedLatest commit: 6cc5dd8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
This stops the immediate second refresh attempt, but for a provider without The new auth(provider, originalOptions)starts again with That is particularly visible for the headless/no-op redirect case the warning already discusses, or any transport/application that retries auth before a fresh authorization callback has replaced the stored tokens. If the intended guarantee is "do not resend a refresh token after this process has observed invalid_grant/invalid_dpop_proof", could the provider/session layer keep an in-memory rejected-token marker until new tokens are saved? Otherwise I would narrow the changeset/title to "avoid the immediate retry" because cross-call replay still occurs by design. A regression calling AI-assisted review; checked the current head and auth retry/token-loading path before posting. |
|
It would be great to get this in. It's affecting downstream applications such as MCPHub. |
auth() treats invalid_grant as recoverable: it warns, calls the optional
provider.invalidateCredentials('tokens') and retries authInternal. When a
provider does not implement invalidateCredentials, tokens() still returns
the refresh token the authorization server just rejected, so the retry
refreshes with it again, gets invalid_grant again, and that second error
reaches the caller. The warning says it is retrying authorization, but
authorization is never reached.
Pass forceReauthorization on that retry so it skips the refresh branch
and starts a new authorization request, which is what the warning and
the v2 migration guide already describe. Providers that implement
invalidateCredentials see no change.
Refs modelcontextprotocol#2915
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Record the refresh token an authorization server rejected with invalid_grant or invalid_dpop_proof, per provider, and skip the refresh branch while tokens() still returns it. This covers later auth() calls as well as the immediate retry, so the forceReauthorization flag on the retry is no longer needed. A different stored refresh token is refreshed as usual.
0641bcc to
6cc5dd8
Compare
|
@soyeladice-svg you're right, the retry flag only covered the immediate retry. Changed it to what you suggested: Added the regression you described (two |
When a refresh fails with
invalid_grantorinvalid_dpop_proof,auth()no longer sends that refresh token to the token endpoint again, in the immediate retry or in laterauth()calls. It goes straight to a new authorization request until a different refresh token is stored.Refs #2915 (this is its second half; the concurrent-refresh half is #1760, handled by #2757)
Motivation and Context
auth()treatsinvalid_grantas recoverable: it warns, callsprovider.invalidateCredentials?.('tokens')and retriesauthInternal.invalidateCredentialsis optional. When a provider does not implement it,tokens()still returns the refresh token the authorization server just rejected, so the retry refreshes with it again, getsinvalid_grantagain, and that second error reaches the caller. The warning says "retrying authorization", but authorization is never reached, and the session stays unusable until the user reconnects.Sending the same refresh token again cannot succeed (RFC 6749 §5.2: the grant "is invalid, expired, revoked, ..."), and the MCP authorization spec requires refresh token rotation for public clients.
The fix records the rejected refresh token per provider (a
WeakMapkeyed on the provider object) when the refresh fails withinvalid_grantorinvalid_dpop_proof, and skips the refresh branch whiletokens()still returns that token. The retry and any laterauth()call therefore start a new authorization request, which is what the warning and the v2 migration guide ("invalid_grant... are still recovered by discarding stored credentials and retrying") already describe. Once a new authorization or another writer saves a different refresh token, it is refreshed as usual. Only a refresh failure records the token, so aninvalid_grantfrom a code exchange is unaffected.invalid_clientandunauthorized_clientkeep their current retry: skipping the refresh would not help there, since the retry would still authorize with the client credentials the server just rejected.How Has This Been Tested?
auth()unit test that pinned the old outcome (added in fix(client/auth): propagate saveTokens errors after refresh #2053). It now expects'REDIRECT', one token request instead of two, and oneredirectToAuthorization()call. Onmainit fails withOAuthError: Refresh token expired, thrown from the retry.does not resend a rejected refresh token on a later auth() call either: twoauth()calls against the provider withoutinvalidateCredentials()make one token request in total. It fails with the retry-only fix (two requests).refreshes again once a different refresh token is stored after a rejection.mockProvider.invalid_grantduring refresh. Its mockinvalidateCredentials()clears nothing, so it was asserting the same replay. It now expectsUnauthorizedErrorafter one redirect.pnpm typecheck:all,pnpm lint:allandpnpm build:allpass. Earlier, on Node 24:pnpm typecheck:all,pnpm lint:all,pnpm build:all,pnpm check:all,node scripts/smoke-dist-types.mjs,pnpm test:all,pnpm test:conformance:client:alland the 2026 client conformance suite pass. The onlytest:allfailure wasprotocol:timeout:max-total [sse]in the e2e package, which also fails intermittently on unmodifiedmainand has no auth in it.Breaking Changes
Providers that implement
invalidateCredentials()see no change, because their tokens are already gone when the retry runs. For providers that do not,auth()now returns'REDIRECT'afterinvalid_grantinstead of throwingOAuthError, and the transports throwUnauthorizedError. If another caller saved a newer refresh token between the failure and the retry, the retry still refreshes with it, as before.Types of changes
Checklist
Additional context
#2757 moves the body of
auth()into a new function, so whichever PR lands second needs a one-line rebase.