#21020·ai

`@ai-sdk/mcp` wipes pre-registered OAuth clients on `invalid_client` and masks the error during code exchange

Author: JHawk0224Created Sep 17, 2026Updated Sep 17, 2026
Labelstask-identify-issue-type-donetask-bug-reproduction-successfactory-activefactory-automatictask-identify-harness-labels-done

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 code

Callers 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).

Current auth() recovery
typescript
export 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_client during 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. Forgetting client_id / client_secret is only a plausible recovery for a DCR-issued client on a new auth (no authorizationCode), where retry can call registerClient().
  • invalid_grantinvalidateCredentials('tokens') is fine.

Actual behavior

  1. Token endpoint returns invalid_client.
  2. SDK deletes client ID, secret, tokens, and verifier.
  3. Retry of the same callback throws Existing OAuth client information is required when exchanging an authorization code.
  4. 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.

Minimal reproduction
typescript
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

Code of Conduct

  • I agree to follow this project's Code of Conduct