From a9ab5ea8f585d40df43fdbda2e5053519da82cf9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:56:26 +0000 Subject: [PATCH 1/5] Restore deprecated OAuth error subclasses and add transient-error helper The v1->v2 consolidation of the per-code OAuth error subclasses into a single OAuthError class silently breaks two common consumer patterns: - instanceof classification (e.g. catching InvalidGrantError to drop stored tokens) stops matching with no compile error in loosely typed code paths. - retry classification keyed on instanceof ServerError loses unknown error codes: v1 collapsed unrecognized codes into ServerError (hence retryable), while v2 preserves the raw code, so a mechanical rewrite to code === OAuthErrorCode.ServerError silently drops retries for non-standard codes like invalid_refresh_token. Re-add the subclasses as deprecated wrappers, construct them from oauthErrorFromCode() so SDK-produced errors satisfy instanceof checks, restore the deprecated OAUTH_ERRORS map and an errorCode alias, and add isTransientOAuthError() encoding the v1 retry semantics including the unknown-code case. The OAuth InvalidRequestError could not keep its name (taken by the JSON-RPC protocol type) and returns as OAuthInvalidRequestError. --- packages/core/src/auth/errors.ts | 236 +++++++++++++++++++++- packages/core/src/exports/public/index.ts | 26 ++- packages/core/test/auth/errors.test.ts | 108 ++++++++++ 3 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/auth/errors.test.ts diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index 30c8741601..c89212979d 100644 --- a/packages/core/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -107,6 +107,16 @@ export class OAuthError extends Error { this.name = 'OAuthError'; } + /** + * The OAuth error code. + * + * @deprecated Use {@linkcode OAuthError.code} instead. Provided for compatibility with + * SDK 1.x, where the error code was exposed as `errorCode`. + */ + get errorCode(): string { + return this.code; + } + /** * Converts the error to a standard OAuth error response object. */ @@ -125,8 +135,232 @@ export class OAuthError extends Error { /** * Creates an {@linkcode OAuthError} from an OAuth error response. + * + * Returns the specific subclass for known error codes (e.g. {@linkcode InvalidGrantError} + * for `invalid_grant`), so `instanceof` checks written against SDK 1.x keep working. */ static fromResponse(response: OAuthErrorResponse): OAuthError { - return new OAuthError(response.error as OAuthErrorCode, response.error_description ?? response.error, response.error_uri); + return oauthErrorFromCode(response.error, response.error_description ?? response.error, response.error_uri); + } +} + +/** + * Base shape shared by the deprecated SDK 1.x error subclasses below. + * + * In SDK 1.x every OAuth error code had its own `OAuthError` subclass and consumers + * classified errors with `instanceof` (e.g. `error instanceof InvalidGrantError` to drop + * stored tokens, `error instanceof ServerError` to retry). These subclasses are preserved + * so that classification keeps working after migrating to 2.x. + * + * Note: unlike 1.x, `error.name` is `'OAuthError'` for all subclasses, matching the 2.x + * base class. Code that needs the specific kind should check `error.code` or `instanceof`. + */ +type OAuthErrorSubclass = new (message: string, errorUri?: string) => OAuthError; + +/** + * The one 1.x OAuth error class that could not keep its original name: 2.x already exports a + * JSON-RPC `InvalidRequestError` interface from the protocol types. Code migrating from 1.x + * that checked `error instanceof InvalidRequestError` (uncommon in clients — this error is + * produced by request validation on the server side) should check + * `error.code === OAuthErrorCode.InvalidRequest` instead. + * + * @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidRequest`. + */ +export class OAuthInvalidRequestError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidRequest, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidClient`. */ +export class InvalidClientError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidClient, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidGrant`. */ +export class InvalidGrantError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidGrant, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnauthorizedClient`. */ +export class UnauthorizedClientError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnauthorizedClient, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnsupportedGrantType`. */ +export class UnsupportedGrantTypeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnsupportedGrantType, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidScope`. */ +export class InvalidScopeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidScope, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.AccessDenied`. */ +export class AccessDeniedError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.AccessDenied, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.ServerError`. */ +export class ServerError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.ServerError, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.TemporarilyUnavailable`. */ +export class TemporarilyUnavailableError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.TemporarilyUnavailable, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnsupportedResponseType`. */ +export class UnsupportedResponseTypeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnsupportedResponseType, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnsupportedTokenType`. */ +export class UnsupportedTokenTypeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnsupportedTokenType, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidToken`. */ +export class InvalidTokenError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidToken, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.MethodNotAllowed`. */ +export class MethodNotAllowedError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.MethodNotAllowed, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.TooManyRequests`. */ +export class TooManyRequestsError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.TooManyRequests, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidClientMetadata`. */ +export class InvalidClientMetadataError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidClientMetadata, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InsufficientScope`. */ +export class InsufficientScopeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InsufficientScope, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidTarget`. */ +export class InvalidTargetError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidTarget, message, errorUri); + } +} + +/** + * An OAuth error with a non-standard error code. + * + * @deprecated Use {@linkcode OAuthError} directly — the 2.x base class carries arbitrary + * codes. Provided for compatibility with SDK 1.x. + */ +export class CustomOAuthError extends OAuthError { + constructor(code: string, message: string, errorUri?: string) { + super(code, message, errorUri); + } +} + +/** + * Maps known OAuth error codes to their corresponding error subclasses. + * + * @deprecated Use {@linkcode oauthErrorFromCode} to construct errors from codes, or check + * `error.code` directly. + */ +export const OAUTH_ERRORS: Readonly> = { + [OAuthErrorCode.InvalidRequest]: OAuthInvalidRequestError, + [OAuthErrorCode.InvalidClient]: InvalidClientError, + [OAuthErrorCode.InvalidGrant]: InvalidGrantError, + [OAuthErrorCode.UnauthorizedClient]: UnauthorizedClientError, + [OAuthErrorCode.UnsupportedGrantType]: UnsupportedGrantTypeError, + [OAuthErrorCode.InvalidScope]: InvalidScopeError, + [OAuthErrorCode.AccessDenied]: AccessDeniedError, + [OAuthErrorCode.ServerError]: ServerError, + [OAuthErrorCode.TemporarilyUnavailable]: TemporarilyUnavailableError, + [OAuthErrorCode.UnsupportedResponseType]: UnsupportedResponseTypeError, + [OAuthErrorCode.UnsupportedTokenType]: UnsupportedTokenTypeError, + [OAuthErrorCode.InvalidToken]: InvalidTokenError, + [OAuthErrorCode.MethodNotAllowed]: MethodNotAllowedError, + [OAuthErrorCode.TooManyRequests]: TooManyRequestsError, + [OAuthErrorCode.InvalidClientMetadata]: InvalidClientMetadataError, + [OAuthErrorCode.InsufficientScope]: InsufficientScopeError, + [OAuthErrorCode.InvalidTarget]: InvalidTargetError +}; + +/** + * Error codes that indicate a transient condition where retrying the request may succeed. + */ +const TRANSIENT_OAUTH_ERROR_CODES: ReadonlySet = new Set([ + OAuthErrorCode.ServerError, + OAuthErrorCode.TemporarilyUnavailable, + OAuthErrorCode.TooManyRequests +]); + +const KNOWN_OAUTH_ERROR_CODES: ReadonlySet = new Set(Object.values(OAuthErrorCode)); + +/** + * Creates an {@linkcode OAuthError} from an error code, returning the specific subclass for + * known codes so that `instanceof` checks (e.g. `error instanceof InvalidGrantError`) work. + * + * Unknown / non-standard codes produce a plain {@linkcode OAuthError} that preserves the + * raw code. + */ +export function oauthErrorFromCode(code: OAuthErrorCode | string, message: string, errorUri?: string): OAuthError { + const ErrorClass = OAUTH_ERRORS[code]; + if (ErrorClass) { + return new ErrorClass(message, errorUri); + } + return new OAuthError(code, message, errorUri); +} + +/** + * Returns `true` when an OAuth error indicates a transient condition that may succeed on + * retry: `server_error`, `temporarily_unavailable`, `too_many_requests` — or any error code + * not defined in {@linkcode OAuthErrorCode}. + * + * Treating unknown codes as transient matches SDK 1.x, which collapsed unrecognized error + * codes into `ServerError`. Authorization servers in the wild return non-standard codes + * (e.g. `invalid_refresh_token`) for conditions that are not permanent; treating them as + * permanent failures would stop retry/refresh loops that previously recovered. + */ +export function isTransientOAuthError(error: unknown): boolean { + if (!(error instanceof OAuthError)) { + return false; } + return TRANSIENT_OAUTH_ERROR_CODES.has(error.code) || !KNOWN_OAUTH_ERROR_CODES.has(error.code); } diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 729144f1a3..81ebd7c2ba 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -10,7 +10,31 @@ */ // Auth error classes -export { OAuthError, OAuthErrorCode } from '../../auth/errors.js'; +export { + AccessDeniedError, + CustomOAuthError, + InsufficientScopeError, + InvalidClientError, + InvalidClientMetadataError, + InvalidGrantError, + InvalidScopeError, + InvalidTargetError, + InvalidTokenError, + isTransientOAuthError, + MethodNotAllowedError, + OAUTH_ERRORS, + OAuthError, + OAuthErrorCode, + oauthErrorFromCode, + OAuthInvalidRequestError, + ServerError, + TemporarilyUnavailableError, + TooManyRequestsError, + UnauthorizedClientError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, + UnsupportedTokenTypeError +} from '../../auth/errors.js'; // SDK error types (local errors that never cross the wire) export type { SdkHttpErrorData } from '../../errors/sdkErrors.js'; diff --git a/packages/core/test/auth/errors.test.ts b/packages/core/test/auth/errors.test.ts new file mode 100644 index 0000000000..1b17037f60 --- /dev/null +++ b/packages/core/test/auth/errors.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + CustomOAuthError, + InvalidClientMetadataError, + InvalidGrantError, + isTransientOAuthError, + OAUTH_ERRORS, + OAuthError, + oauthErrorFromCode, + OAuthErrorCode, + ServerError, + TemporarilyUnavailableError, + TooManyRequestsError +} from '../../src/auth/errors.js'; + +describe('oauthErrorFromCode', () => { + it('returns the specific subclass for every known error code', () => { + for (const code of Object.values(OAuthErrorCode)) { + const error = oauthErrorFromCode(code, 'boom'); + expect(error).toBeInstanceOf(OAuthError); + expect(error).toBeInstanceOf(OAUTH_ERRORS[code]); + expect(error.code).toBe(code); + expect(error.message).toBe('boom'); + } + }); + + it('returns a plain OAuthError preserving the raw code for unknown codes', () => { + const error = oauthErrorFromCode('invalid_refresh_token', 'token rotated'); + expect(error).toBeInstanceOf(OAuthError); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('invalid_refresh_token'); + }); + + it('passes errorUri through', () => { + const error = oauthErrorFromCode(OAuthErrorCode.InvalidGrant, 'boom', 'https://example.com/error'); + expect(error.errorUri).toBe('https://example.com/error'); + }); +}); + +describe('OAuthError.fromResponse', () => { + it('produces subclass instances so 1.x instanceof checks keep working', () => { + const error = OAuthError.fromResponse({ error: 'invalid_grant', error_description: 'expired' }); + expect(error).toBeInstanceOf(InvalidGrantError); + expect(error.code).toBe(OAuthErrorCode.InvalidGrant); + expect(error.message).toBe('expired'); + }); + + it('preserves unknown codes on the base class', () => { + const error = OAuthError.fromResponse({ error: 'consent_required' }); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('consent_required'); + }); +}); + +describe('deprecated 1.x subclasses', () => { + it('construct with (message, errorUri) and carry the right code', () => { + const error = new InvalidGrantError('expired', 'https://example.com/error'); + expect(error.code).toBe(OAuthErrorCode.InvalidGrant); + expect(error.message).toBe('expired'); + expect(error.errorUri).toBe('https://example.com/error'); + expect(error).toBeInstanceOf(OAuthError); + }); + + it('keep name set to OAuthError for 2.x name-based checks', () => { + expect(new InvalidClientMetadataError('bad').name).toBe('OAuthError'); + }); + + it('CustomOAuthError carries an arbitrary code', () => { + const error = new CustomOAuthError('weird_code', 'odd'); + expect(error.code).toBe('weird_code'); + expect(error).toBeInstanceOf(OAuthError); + }); + + it('expose the deprecated errorCode alias', () => { + expect(new ServerError('boom').errorCode).toBe(OAuthErrorCode.ServerError); + expect(new OAuthError('custom_thing', 'boom').errorCode).toBe('custom_thing'); + }); + + it('OAUTH_ERRORS covers every OAuthErrorCode member', () => { + for (const code of Object.values(OAuthErrorCode)) { + expect(OAUTH_ERRORS[code]).toBeDefined(); + } + }); +}); + +describe('isTransientOAuthError', () => { + it('is true for the RFC transient codes', () => { + expect(isTransientOAuthError(new ServerError('boom'))).toBe(true); + expect(isTransientOAuthError(new TemporarilyUnavailableError('busy'))).toBe(true); + expect(isTransientOAuthError(new TooManyRequestsError('slow down'))).toBe(true); + }); + + it('is true for unknown codes (1.x collapsed them into ServerError, hence retryable)', () => { + expect(isTransientOAuthError(oauthErrorFromCode('invalid_refresh_token', 'rotated'))).toBe(true); + expect(isTransientOAuthError(new CustomOAuthError('proprietary_hiccup', 'try later'))).toBe(true); + }); + + it('is false for known permanent codes', () => { + expect(isTransientOAuthError(oauthErrorFromCode(OAuthErrorCode.InvalidGrant, 'expired'))).toBe(false); + expect(isTransientOAuthError(oauthErrorFromCode(OAuthErrorCode.AccessDenied, 'no'))).toBe(false); + }); + + it('is false for non-OAuth errors', () => { + expect(isTransientOAuthError(new Error('boom'))).toBe(false); + expect(isTransientOAuthError(undefined)).toBe(false); + }); +}); From 9a729ea491ccb240be712539c0918a0ac2c56698 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:56:26 +0000 Subject: [PATCH 2/5] Construct client OAuth errors through oauthErrorFromCode parseErrorResponse and the client-metadata validation throw sites now produce the specific deprecated subclass for known error codes, so instanceof checks written against SDK 1.x keep working for errors thrown by the SDK itself. Unparsable error bodies fall back to a ServerError instance, matching 1.x. --- packages/client/src/client/auth.ts | 7 ++-- .../test/client/authErrorCompat.test.ts | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 packages/client/test/client/authErrorCompat.test.ts diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a08..d961446797 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -15,6 +15,7 @@ import { LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, + oauthErrorFromCode, OAuthErrorCode, OAuthErrorResponseSchema, OAuthMetadataSchema, @@ -527,7 +528,7 @@ export async function parseErrorResponse(input: Response | string): Promise { + it('returns the specific subclass for known OAuth error codes', async () => { + const response = new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'refresh token expired' }), { + status: 400 + }); + const error = await parseErrorResponse(response); + expect(error).toBeInstanceOf(InvalidGrantError); + expect(error.code).toBe('invalid_grant'); + expect(isTransientOAuthError(error)).toBe(false); + }); + + it('preserves unknown error codes on the base class and classifies them transient', async () => { + const response = new Response(JSON.stringify({ error: 'invalid_refresh_token', error_description: 'rotated' }), { + status: 400 + }); + const error = await parseErrorResponse(response); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('invalid_refresh_token'); + expect(isTransientOAuthError(error)).toBe(true); + }); + + it('falls back to ServerError for unparsable bodies, matching 1.x', async () => { + const response = new Response('gateway timeout', { status: 502 }); + const error = await parseErrorResponse(response); + expect(error).toBeInstanceOf(ServerError); + expect(isTransientOAuthError(error)).toBe(true); + }); +}); From 3988c554637bb06cd07236766b3c806209b1cf29 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:56:26 +0000 Subject: [PATCH 3/5] Document OAuth error compatibility and unknown-code retry semantics --- .changeset/oauth-error-compat.md | 8 +++ docs/migration-SKILL.md | 64 ++++++++--------------- docs/migration.md | 90 ++++++++++++-------------------- 3 files changed, 63 insertions(+), 99 deletions(-) create mode 100644 .changeset/oauth-error-compat.md diff --git a/.changeset/oauth-error-compat.md b/.changeset/oauth-error-compat.md new file mode 100644 index 0000000000..4585909a84 --- /dev/null +++ b/.changeset/oauth-error-compat.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': patch +--- + +Restore OAuth error backwards compatibility with SDK 1.x: the per-code error subclasses (`InvalidGrantError`, `ServerError`, …) are exported again as deprecated wrappers around `OAuthError`, and SDK-produced OAuth errors are constructed as the matching subclass so 1.x-style +`instanceof` classification keeps working. Adds `oauthErrorFromCode()`, `isTransientOAuthError()` (which treats unknown error codes as transient, matching 1.x retry semantics), a deprecated `errorCode` alias for `code`, and the deprecated `OAUTH_ERRORS` map. The OAuth +`InvalidRequestError` returns as `OAuthInvalidRequestError` (the original name is taken by a JSON-RPC protocol type). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..27eb1a58f8 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -178,52 +178,34 @@ if (error instanceof SdkHttpError) { ### OAuth error consolidation -Individual OAuth error classes replaced with single `OAuthError` class and `OAuthErrorCode` enum: - -| v1 Class | v2 Equivalent | -| ------------------------------ | ---------------------------------------------------------- | -| `InvalidRequestError` | `OAuthError` with `OAuthErrorCode.InvalidRequest` | -| `InvalidClientError` | `OAuthError` with `OAuthErrorCode.InvalidClient` | -| `InvalidGrantError` | `OAuthError` with `OAuthErrorCode.InvalidGrant` | -| `UnauthorizedClientError` | `OAuthError` with `OAuthErrorCode.UnauthorizedClient` | -| `UnsupportedGrantTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedGrantType` | -| `InvalidScopeError` | `OAuthError` with `OAuthErrorCode.InvalidScope` | -| `AccessDeniedError` | `OAuthError` with `OAuthErrorCode.AccessDenied` | -| `ServerError` | `OAuthError` with `OAuthErrorCode.ServerError` | -| `TemporarilyUnavailableError` | `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable` | -| `UnsupportedResponseTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedResponseType` | -| `UnsupportedTokenTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedTokenType` | -| `InvalidTokenError` | `OAuthError` with `OAuthErrorCode.InvalidToken` | -| `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` | -| `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` | -| `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` | -| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` | -| `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -Removed: `OAUTH_ERRORS` constant. - -Update OAuth error handling: +OAuth errors consolidated into `OAuthError` with `OAuthErrorCode` enum on `error.code`. The v1 subclasses remain exported as `@deprecated` compatibility wrappers, and SDK-produced errors are constructed as the matching subclass — `instanceof` checks from v1 continue to work +WITHOUT code changes, with ONE exception: -```typescript -// v1 -import { InvalidClientError, InvalidGrantError } from '@modelcontextprotocol/client'; -if (error instanceof InvalidClientError) { ... } - -// v2 -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } -``` +| v1 Class | v2 status | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `InvalidRequestError` (OAuth) | RENAMED → `OAuthInvalidRequestError` (name now owned by a JSON-RPC protocol type). Rewrite `instanceof InvalidRequestError` → `error.code === OAuthErrorCode.InvalidRequest` | +| All 16 other subclasses (`InvalidGrantError`, `ServerError`, …) and `CustomOAuthError` | Unchanged names, deprecated; prefer `error.code === OAuthErrorCode.X` | +| `OAUTH_ERRORS` | Still exported, deprecated; prefer `oauthErrorFromCode(code, message, errorUri?)` | -**Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all -Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11). +Semantics to preserve when rewriting v1 code: -## 6. McpServer API Changes +1. `error.errorCode` → `error.code` (deprecated alias exists). +2. `error.name` is `'OAuthError'` for ALL OAuth errors including subclasses (v1 used the subclass name). Never match on subclass names. +3. UNKNOWN-CODE TRAP: v1 collapsed unrecognized error codes into `ServerError` (transient/retryable). v2 preserves the raw code on a plain `OAuthError`. Rewriting `instanceof ServerError` retry checks as `error.code === OAuthErrorCode.ServerError` SILENTLY DROPS retry for + non-standard codes (e.g. `invalid_refresh_token`). Use `isTransientOAuthError(error)` instead — it returns true for `server_error`, `temporarily_unavailable`, `too_many_requests`, AND any code not in `OAuthErrorCode` (v1 parity). -The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. +```typescript +// v1 +if (error instanceof ServerError) { + scheduleRetry(); +} -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with -`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +// v2 — equivalent semantics +import { isTransientOAuthError } from '@modelcontextprotocol/client'; +if (isTransientOAuthError(error)) { + scheduleRetry(); +} +``` ### Tools diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..74e94ed4b6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -798,85 +798,59 @@ The new design: ### OAuth error refactoring -The OAuth error classes have been consolidated into a single `OAuthError` class with an `OAuthErrorCode` enum. +OAuth errors are now built around a single `OAuthError` class with an `OAuthErrorCode` enum: `error.code` carries the OAuth error code (e.g. `'invalid_grant'`), including non-standard codes returned by real-world authorization servers. -#### Removed classes - -The following individual error classes have been removed in favor of `OAuthError` with the appropriate code: - -| v1 Class | v2 Equivalent | -| ------------------------------ | ----------------------------------------------------------------- | -| `InvalidRequestError` | `new OAuthError(OAuthErrorCode.InvalidRequest, message)` | -| `InvalidClientError` | `new OAuthError(OAuthErrorCode.InvalidClient, message)` | -| `InvalidGrantError` | `new OAuthError(OAuthErrorCode.InvalidGrant, message)` | -| `UnauthorizedClientError` | `new OAuthError(OAuthErrorCode.UnauthorizedClient, message)` | -| `UnsupportedGrantTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedGrantType, message)` | -| `InvalidScopeError` | `new OAuthError(OAuthErrorCode.InvalidScope, message)` | -| `AccessDeniedError` | `new OAuthError(OAuthErrorCode.AccessDenied, message)` | -| `ServerError` | `new OAuthError(OAuthErrorCode.ServerError, message)` | -| `TemporarilyUnavailableError` | `new OAuthError(OAuthErrorCode.TemporarilyUnavailable, message)` | -| `UnsupportedResponseTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedResponseType, message)` | -| `UnsupportedTokenTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedTokenType, message)` | -| `InvalidTokenError` | `new OAuthError(OAuthErrorCode.InvalidToken, message)` | -| `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` | -| `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` | -| `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` | -| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` | -| `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -The `OAUTH_ERRORS` constant has also been removed. - -If you need the v1 OAuth error classes and `mcpAuthRouter` during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy: - -```typescript -import { mcpAuthRouter, InvalidClientError } from '@modelcontextprotocol/server-legacy/auth'; -``` - -This package is deprecated and will not receive new features. Use a dedicated OAuth provider in production. - -**Before (v1):** +For backwards compatibility, the v1 error subclasses are still exported (as `@deprecated` wrappers around `OAuthError`), and errors produced by the SDK from server error responses are constructed as the matching subclass — so v1-style `instanceof` classification keeps working: ```typescript -import { InvalidClientError, InvalidGrantError, ServerError } from '@modelcontextprotocol/client'; +import { InvalidGrantError, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; try { await refreshToken(); } catch (error) { - if (error instanceof InvalidClientError) { - // Handle invalid client - } else if (error instanceof InvalidGrantError) { - // Handle invalid grant - } else if (error instanceof ServerError) { - // Handle server error + // v1 style — still works: + if (error instanceof InvalidGrantError) { + // Drop stored tokens, re-authorize + } + // v2 style — preferred: + if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidGrant) { + // Drop stored tokens, re-authorize } } ``` -**After (v2):** +Differences to be aware of when migrating: + +- **One rename:** the OAuth `InvalidRequestError` class is now `OAuthInvalidRequestError`, because v2 exports a JSON-RPC `InvalidRequestError` interface from the protocol types under the same name. Check `error.code === OAuthErrorCode.InvalidRequest` instead. +- **`error.name` is always `'OAuthError'`**, including for the subclasses (v1 used the subclass name). Use `instanceof` or `error.code` to distinguish kinds. +- **`error.errorCode` is deprecated** — it remains available as an alias, but use `error.code`. +- **Unknown error codes are preserved.** v1 collapsed unrecognized codes (e.g. `invalid_refresh_token`) into `ServerError`, which made them look transient/retryable. v2 keeps the raw code on a plain `OAuthError`. If you had retry logic keyed on `instanceof ServerError`, use the + new `isTransientOAuthError(error)` helper, which treats the RFC transient codes _and_ unknown codes as retryable — matching v1 behavior: ```typescript -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; +import { isTransientOAuthError } from '@modelcontextprotocol/client'; try { await refreshToken(); } catch (error) { - if (error instanceof OAuthError) { - switch (error.code) { - case OAuthErrorCode.InvalidClient: - // Handle invalid client - break; - case OAuthErrorCode.InvalidGrant: - // Handle invalid grant - break; - case OAuthErrorCode.ServerError: - // Handle server error - break; - } + if (isTransientOAuthError(error)) { + scheduleRetry(); + } else { + throw error; } } ``` +- `OAUTH_ERRORS` (the code → class map) is still exported, `@deprecated`. `oauthErrorFromCode(code, message, errorUri?)` is the supported way to construct an error from a code. + +If you need the full v1 OAuth _server_ surface (`mcpAuthRouter` and friends) during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy: + +```typescript +import { mcpAuthRouter } from '@modelcontextprotocol/server-legacy/auth'; +``` + +This package is deprecated and will not receive new features. Use a dedicated OAuth provider in production. Note that error classes imported from `server-legacy/auth` are distinct from the `@modelcontextprotocol/client` ones — don't mix them in `instanceof` checks. + ### Experimental tasks interception removed The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement: From ec7dbf171e29db9aba42697bc3019cd2f7da4dd8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 08:22:26 +0000 Subject: [PATCH 4/5] Add token-endpoint round-trip tests for OAuth error classes Exercise refreshAuthorization with an injected fetchFn returning error responses, asserting consumer-visible error classes and transient classification through the full request path rather than only via parseErrorResponse directly. --- .../test/client/authErrorCompat.test.ts | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/client/test/client/authErrorCompat.test.ts b/packages/client/test/client/authErrorCompat.test.ts index 22affdcf60..2a705da90a 100644 --- a/packages/client/test/client/authErrorCompat.test.ts +++ b/packages/client/test/client/authErrorCompat.test.ts @@ -1,7 +1,7 @@ -import { InvalidGrantError, isTransientOAuthError, OAuthError, ServerError } from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; +import { InvalidGrantError, isTransientOAuthError, OAuthError, ServerError, TemporarilyUnavailableError } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; -import { parseErrorResponse } from '../../src/client/auth.js'; +import { parseErrorResponse, refreshAuthorization } from '../../src/client/auth.js'; describe('parseErrorResponse error-class compatibility', () => { it('returns the specific subclass for known OAuth error codes', async () => { @@ -31,3 +31,55 @@ describe('parseErrorResponse error-class compatibility', () => { expect(isTransientOAuthError(error)).toBe(true); }); }); + +describe('refreshAuthorization error-class compatibility', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + const clientInformation = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'] + }; + + function refreshWith(fetchFn: typeof fetch) { + return refreshAuthorization('https://auth.example.com', { + metadata, + clientInformation, + refreshToken: 'refresh123', + fetchFn + }); + } + + it('throws InvalidGrantError when the token endpoint rejects the refresh token', async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'refresh token expired' }), { status: 400 }) + ); + const error = await refreshWith(fetchFn).catch(e => e); + expect(error).toBeInstanceOf(InvalidGrantError); + expect(error.code).toBe('invalid_grant'); + expect(isTransientOAuthError(error)).toBe(false); + }); + + it('preserves unknown token-endpoint error codes and classifies them transient', async () => { + const fetchFn = vi.fn( + async () => new Response(JSON.stringify({ error: 'invalid_refresh_token', error_description: 'rotated' }), { status: 400 }) + ); + const error = await refreshWith(fetchFn).catch(e => e); + expect(error).toBeInstanceOf(OAuthError); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('invalid_refresh_token'); + expect(isTransientOAuthError(error)).toBe(true); + }); + + it('throws TemporarilyUnavailableError for a 503 with the matching code', async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { status: 503 })); + const error = await refreshWith(fetchFn).catch(e => e); + expect(error).toBeInstanceOf(TemporarilyUnavailableError); + expect(isTransientOAuthError(error)).toBe(true); + }); +}); From 3ea50ff8672c9417c470c44ae224e1655f57dccc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 15:24:24 +0000 Subject: [PATCH 5/5] Widen auth() refresh fallback to v1-parity error classification; guard OAUTH_ERRORS lookup; restore deleted McpServer section in migration-SKILL.md --- docs/migration-SKILL.md | 10 +++ packages/client/src/client/auth.ts | 9 +- .../test/client/authErrorCompat.test.ts | 88 ++++++++++++++++++- packages/core/src/auth/errors.ts | 4 +- packages/core/test/auth/errors.test.ts | 10 +++ 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 27eb1a58f8..b97dab7e26 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -207,6 +207,16 @@ if (isTransientOAuthError(error)) { } ``` +**Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all +Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11). + +## 6. McpServer API Changes + +The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. + +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. + ### Tools ```typescript diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d961446797..9a4e384484 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -12,11 +12,12 @@ import type { } from '@modelcontextprotocol/core'; import { checkResourceAllowed, + isTransientOAuthError, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, - oauthErrorFromCode, OAuthErrorCode, + oauthErrorFromCode, OAuthErrorResponseSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, @@ -778,8 +779,10 @@ async function authInternal( await provider.saveTokens(newTokens); return 'AUTHORIZED'; } catch (error) { - // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. - if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) { + // If this is a transient OAuth error or an unrecognized code (which 1.x collapsed + // into ServerError), or not an OAuth error at all, log it out and try to continue + // with a fresh authorization flow. Otherwise, escalate so we can fix things and retry. + if (!(error instanceof OAuthError) || isTransientOAuthError(error)) { // Could not refresh OAuth tokens } else { // Refresh failed for another reason, re-throw diff --git a/packages/client/test/client/authErrorCompat.test.ts b/packages/client/test/client/authErrorCompat.test.ts index 2a705da90a..4183f6718b 100644 --- a/packages/client/test/client/authErrorCompat.test.ts +++ b/packages/client/test/client/authErrorCompat.test.ts @@ -1,7 +1,15 @@ -import { InvalidGrantError, isTransientOAuthError, OAuthError, ServerError, TemporarilyUnavailableError } from '@modelcontextprotocol/core'; +import { + InvalidGrantError, + InvalidScopeError, + isTransientOAuthError, + OAuthError, + ServerError, + TemporarilyUnavailableError +} from '@modelcontextprotocol/core'; import { describe, expect, it, vi } from 'vitest'; -import { parseErrorResponse, refreshAuthorization } from '../../src/client/auth.js'; +import type { OAuthClientProvider } from '../../src/client/auth.js'; +import { auth, parseErrorResponse, refreshAuthorization } from '../../src/client/auth.js'; describe('parseErrorResponse error-class compatibility', () => { it('returns the specific subclass for known OAuth error codes', async () => { @@ -83,3 +91,79 @@ describe('refreshAuthorization error-class compatibility', () => { expect(isTransientOAuthError(error)).toBe(true); }); }); +describe('auth() refresh-failure fallback', () => { + function makeProvider(): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost:3000/callback'], client_name: 'Test Client' }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'] + }), + tokens: vi.fn().mockResolvedValue({ access_token: 'stale', refresh_token: 'refresh123', token_type: 'bearer' }), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() + }; + } + + function fetchRouter(tokenResponse: () => Response): typeof fetch { + return vi.fn(async (url: string | URL | Request) => { + const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return new Response( + JSON.stringify({ resource: 'https://api.example.com/mcp', authorization_servers: ['https://auth.example.com'] }), + { status: 200 } + ); + } + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }), + { status: 200 } + ); + } + if (urlString.includes('/token')) { + return tokenResponse(); + } + throw new Error(`Unexpected fetch call: ${urlString}`); + }) as unknown as typeof fetch; + } + + it('falls through to a fresh authorization flow when refresh fails with an unknown code', async () => { + const provider = makeProvider(); + const result = await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + fetchFn: fetchRouter( + () => new Response(JSON.stringify({ error: 'invalid_refresh_token', error_description: 'rotated' }), { status: 400 }) + ) + }); + expect(result).toBe('REDIRECT'); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + expect(provider.saveTokens).not.toHaveBeenCalled(); + }); + + it('still escalates known non-transient refresh failures', async () => { + const provider = makeProvider(); + const error = await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + fetchFn: fetchRouter( + () => new Response(JSON.stringify({ error: 'invalid_scope', error_description: 'scope revoked' }), { status: 400 }) + ) + }).catch(e => e); + expect(error).toBeInstanceOf(InvalidScopeError); + expect(error.code).toBe('invalid_scope'); + expect(provider.redirectToAuthorization).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index c89212979d..aed6ad0a28 100644 --- a/packages/core/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -341,7 +341,9 @@ const KNOWN_OAUTH_ERROR_CODES: ReadonlySet = new Set(Object.values(OAuth * raw code. */ export function oauthErrorFromCode(code: OAuthErrorCode | string, message: string, errorUri?: string): OAuthError { - const ErrorClass = OAUTH_ERRORS[code]; + // The code comes from untrusted server responses: guard against prototype-chain + // lookups (e.g. a server returning "constructor" or "__proto__" as the error code). + const ErrorClass = Object.hasOwn(OAUTH_ERRORS, code) ? OAUTH_ERRORS[code] : undefined; if (ErrorClass) { return new ErrorClass(message, errorUri); } diff --git a/packages/core/test/auth/errors.test.ts b/packages/core/test/auth/errors.test.ts index 1b17037f60..9c160a00a2 100644 --- a/packages/core/test/auth/errors.test.ts +++ b/packages/core/test/auth/errors.test.ts @@ -38,6 +38,16 @@ describe('oauthErrorFromCode', () => { }); }); +describe('oauthErrorFromCode prototype-chain safety', () => { + it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])('treats Object.prototype member %s as an unknown code', code => { + const error = oauthErrorFromCode(code, 'server sent a hostile code'); + expect(error).toBeInstanceOf(OAuthError); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe(code); + expect(error.message).toContain('server sent a hostile code'); + }); +}); + describe('OAuthError.fromResponse', () => { it('produces subclass instances so 1.x instanceof checks keep working', () => { const error = OAuthError.fromResponse({ error: 'invalid_grant', error_description: 'expired' });