`@ai-sdk/mcp` wipes pre-registered OAuth clients on `invalid_client` and masks the error during code exchange
Description
auth() treats every InvalidClientError / UnauthorizedClientError as “this DCR client is dead”: it calls invalidateCredentials('all') and retries authInternal with the same options.
That recovery is wrong for a pre-registered confidential client (clientInformation() already returns a static client_id / client_secret). The SDK never checks whether the client was dynamically registered. Wiping it cannot produce a valid replacement, and the retry is especially broken when authorizationCode is set.
We hit this against Snowflake MCP. Authorize succeeded (Snowflake issued a code and redirected). Token POST returned { "error": "invalid_client" }. parseErrorResponse correctly built InvalidClientError. Then auth() deleted the static client and retried the same callback. authInternal threw:
Existing OAuth client information is required when exchanging an authorization codeCallers never see invalid_client. They see an unmapped callback failure.
Source: packages/mcp/src/tool/oauth.ts (auth, authInternal). Same catch/retry is on @ai-sdk/[email protected] and @ai-sdk/[email protected].
Related: #10062 (pre-registered clients are supported by supplying clientInformation(), but this recovery still assumes DCR).
auth() recoveryexport async function auth(provider, options) {
try {
return await authInternal(provider, options)
} catch (error) {
if (
error instanceof InvalidClientError ||
error instanceof UnauthorizedClientError
) {
await provider.invalidateCredentials?.('all')
return await authInternal(provider, options)
}
// ...
}
}Expected behavior
invalid_client/unauthorized_clientduring authorization-code exchange should be rethrown. Do not wipe credentials. Do not retry with the same code.invalidateCredentials('all')should not run for a client the provider already supplied as pre-registered. Forgettingclient_id/client_secretis only a plausible recovery for a DCR-issued client on a new auth (noauthorizationCode), where retry can callregisterClient().invalid_grant→invalidateCredentials('tokens')is fine.
Actual behavior
- Token endpoint returns
invalid_client. - SDK deletes client ID, secret, tokens, and verifier.
- Retry of the same callback throws
Existing OAuth client information is required when exchanging an authorization code. - The original OAuth error is gone.
On a fresh auth() (no code) with a static client, the same catch would delete the pre-registered client and attempt DCR.
Reproduction
auth() is the callback path: serverUrl + authorizationCode, with static clientInformation().
Expected: throws InvalidClientError (errorCode: 'invalid_client'). clientInformation is unchanged.
Actual: throws Error: Existing OAuth client information is required when exchanging an authorization code. clientInformation is undefined. Token invalid_client is not on the thrown error.
import { auth } from '@ai-sdk/mcp'
const mcpServerUrl = 'https://mcp.example.com/mcp'
const asIssuer = 'https://as.example.com'
const tokenUrl = 'https://as.example.com/oauth/token'
let clientInformation = {
client_id: 'static-client',
client_secret: 'wrong-secret',
}
let codeVerifier = 'code-verifier'
let authorizationServerInformation = {
authorizationServerUrl: asIssuer,
tokenEndpoint: tokenUrl,
}
const provider = {
redirectUrl: 'https://client.example.com/callback',
clientMetadata: {
client_name: 'Reproduction',
redirect_uris: ['https://client.example.com/callback'],
token_endpoint_auth_method: 'client_secret_post',
},
clientInformation: () => clientInformation,
saveClientInformation: (value) => {
clientInformation = value
},
tokens: () => undefined,
saveTokens: () => {},
saveCodeVerifier: (value) => {
codeVerifier = value
},
codeVerifier: () => codeVerifier,
authorizationServerInformation: () => authorizationServerInformation,
saveAuthorizationServerInformation: (value) => {
authorizationServerInformation = value
},
invalidateCredentials: async (scope) => {
if (scope === 'all' || scope === 'client') clientInformation = undefined
},
redirectToAuthorization: () => {
throw new Error('should not redirect during code exchange')
},
}
const fetchFn = async (input, init) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url
if (url.includes('/.well-known/oauth-protected-resource')) {
return Response.json({
resource: mcpServerUrl,
authorization_servers: [asIssuer],
})
}
if (url.includes('/.well-known/oauth-authorization-server')) {
return Response.json({
issuer: asIssuer,
authorization_endpoint: `${asIssuer}/authorize`,
token_endpoint: tokenUrl,
registration_endpoint: `${asIssuer}/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256'],
})
}
if (url === tokenUrl && init?.method === 'POST') {
return Response.json(
{
error: 'invalid_client',
error_description: 'This is an invalid client.',
},
{ status: 401 },
)
}
throw new Error(`unexpected fetch: ${url}`)
}
await auth(provider, {
serverUrl: mcpServerUrl,
authorizationCode: 'auth-code',
fetchFn,
})AI SDK Version
@ai-sdk/[email protected]@ai-sdk/[email protected]
Code of Conduct
- I agree to follow this project's Code of Conduct
Source: vercel/ai