Skip to content
Open
6 changes: 6 additions & 0 deletions .changeset/error-compat-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Add v1-compat `@deprecated` error aliases: `McpError`/`ErrorCode` (alias `ProtocolError`/`ProtocolErrorCode`, with `ConnectionClosed`/`RequestTimeout` from `SdkErrorCode`), `OAuthError.errorCode` getter (alias `.code`), `JSONRPCError`/`isJSONRPCError`, 16 of the 17 v1 OAuth error subclasses (`InvalidTokenError`, `ServerError`, … — `InvalidRequestError` is omitted from the public surface to avoid colliding with the `InvalidRequestError` interface in `types.ts`) as thin wrappers around `OAuthError` + `OAuthErrorCode`, and `StreamableHTTPError` as an `SdkError` subclass that the StreamableHTTP client transport now throws (so `instanceof StreamableHTTPError` matches; `.status` carries the HTTP status code).
54 changes: 35 additions & 19 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
isJSONRPCResultResponse,
JSONRPCMessageSchema,
normalizeHeaders,
SdkError,
SdkErrorCode
SdkErrorCode,
StreamableHTTPError
} from '@modelcontextprotocol/core';
import { EventSourceParserStream } from 'eventsource-parser/stream';

Expand Down Expand Up @@ -273,9 +273,13 @@ export class StreamableHTTPClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
});
throw new StreamableHTTPError(
SdkErrorCode.ClientHttpAuthentication,
'Server returned 401 after re-authentication',
{
status: 401
}
);
}
throw new UnauthorizedError();
}
Expand All @@ -288,10 +292,14 @@ export class StreamableHTTPClientTransport implements Transport {
return;
}

throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
throw new StreamableHTTPError(
SdkErrorCode.ClientHttpFailedToOpenStream,
`Failed to open SSE stream: ${response.statusText}`,
{
status: response.status,
statusText: response.statusText
}
);
}

this._handleSseStream(response.body, options, true);
Expand Down Expand Up @@ -581,9 +589,13 @@ export class StreamableHTTPClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
});
throw new StreamableHTTPError(
SdkErrorCode.ClientHttpAuthentication,
'Server returned 401 after re-authentication',
{
status: 401
}
);
}
throw new UnauthorizedError();
}
Expand All @@ -598,7 +610,7 @@ export class StreamableHTTPClientTransport implements Transport {

// Check if we've already tried upscoping with this header to prevent infinite loops.
if (this._lastUpscopingHeader === wwwAuthHeader) {
throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
status: 403,
text
});
Expand Down Expand Up @@ -629,7 +641,7 @@ export class StreamableHTTPClientTransport implements Transport {
}
}

throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
status: response.status,
text
});
Expand Down Expand Up @@ -675,7 +687,7 @@ export class StreamableHTTPClientTransport implements Transport {
}
} else {
await response.text?.().catch(() => {});
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
contentType
});
}
Expand Down Expand Up @@ -725,10 +737,14 @@ export class StreamableHTTPClientTransport implements Transport {
// We specifically handle 405 as a valid response according to the spec,
// meaning the server does not support explicit session termination
if (!response.ok && response.status !== 405) {
throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
throw new StreamableHTTPError(
SdkErrorCode.ClientHttpFailedToTerminateSession,
`Failed to terminate session: ${response.statusText}`,
{
status: response.status,
statusText: response.statusText
}
);
}

this._sessionId = undefined;
Expand Down
6 changes: 4 additions & 2 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode, StreamableHTTPError } from '@modelcontextprotocol/core';
import type { Mock, Mocked } from 'vitest';

import type { OAuthClientProvider } from '../../src/client/auth.js';
Expand Down Expand Up @@ -240,7 +240,7 @@ describe('StreamableHTTPClientTransport', () => {
transport.onerror = errorSpy;

await expect(transport.send(message)).rejects.toThrow(
new SdkError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
new StreamableHTTPError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
status: 404,
text: 'Session not found'
})
Expand Down Expand Up @@ -1872,7 +1872,9 @@ describe('StreamableHTTPClientTransport', () => {

const error = await transport.send(message).catch(e => e);
expect(error).toBeInstanceOf(SdkError);
expect(error).toBeInstanceOf(StreamableHTTPError);
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect((error as StreamableHTTPError).status).toBe(401);
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/auth/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ export class OAuthError extends Error {
this.name = 'OAuthError';
}

/**
* @deprecated Use {@linkcode OAuthError.code | .code}.
*/
get errorCode(): string {
return this.code;
}

/**
* Converts the error to a standard OAuth error response object.
*/
Expand Down
112 changes: 112 additions & 0 deletions packages/core/src/errors/oauthErrorsCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* v1-compat: OAuth error subclasses.
*
* v1 shipped one `Error` subclass per OAuth error code (e.g. `InvalidTokenError`).
* v2 also exposes the consolidated {@link OAuthError} + {@link OAuthErrorCode} enum.
* These thin wrappers preserve `throw new InvalidTokenError(msg)` and `instanceof`
* patterns from v1 and set `.code` to the matching enum value.
*/

import { OAuthError, OAuthErrorCode } from '../auth/errors.js';

type OAuthErrorSubclass = {
new (message: string, errorUri?: string): OAuthError;
/** @deprecated Use the instance `.code` property. */
errorCode: string;
};

function sub(code: OAuthErrorCode, name: string): OAuthErrorSubclass {
const Sub = class extends OAuthError {
static errorCode = code;
constructor(message: string, errorUri?: string) {
super(code, message, errorUri);
this.name = name;
}
};
Object.defineProperty(Sub, 'name', { value: name, configurable: true });
return Sub;
}
Comment thread
felixweinberger marked this conversation as resolved.

/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-redeclare */

/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidRequest`. */
export const InvalidRequestError = sub(OAuthErrorCode.InvalidRequest, 'InvalidRequestError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidRequest`. */
export type InvalidRequestError = InstanceType<typeof InvalidRequestError>;
Comment thread
claude[bot] marked this conversation as resolved.
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClient`. */
export const InvalidClientError = sub(OAuthErrorCode.InvalidClient, 'InvalidClientError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClient`. */
export type InvalidClientError = InstanceType<typeof InvalidClientError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidGrant`. */
export const InvalidGrantError = sub(OAuthErrorCode.InvalidGrant, 'InvalidGrantError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidGrant`. */
export type InvalidGrantError = InstanceType<typeof InvalidGrantError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnauthorizedClient`. */
export const UnauthorizedClientError = sub(OAuthErrorCode.UnauthorizedClient, 'UnauthorizedClientError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnauthorizedClient`. */
export type UnauthorizedClientError = InstanceType<typeof UnauthorizedClientError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedGrantType`. */
export const UnsupportedGrantTypeError = sub(OAuthErrorCode.UnsupportedGrantType, 'UnsupportedGrantTypeError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedGrantType`. */
export type UnsupportedGrantTypeError = InstanceType<typeof UnsupportedGrantTypeError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidScope`. */
export const InvalidScopeError = sub(OAuthErrorCode.InvalidScope, 'InvalidScopeError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidScope`. */
export type InvalidScopeError = InstanceType<typeof InvalidScopeError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.AccessDenied`. */
export const AccessDeniedError = sub(OAuthErrorCode.AccessDenied, 'AccessDeniedError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.AccessDenied`. */
export type AccessDeniedError = InstanceType<typeof AccessDeniedError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.ServerError`. */
export const ServerError = sub(OAuthErrorCode.ServerError, 'ServerError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.ServerError`. */
export type ServerError = InstanceType<typeof ServerError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable`. */
export const TemporarilyUnavailableError = sub(OAuthErrorCode.TemporarilyUnavailable, 'TemporarilyUnavailableError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable`. */
export type TemporarilyUnavailableError = InstanceType<typeof TemporarilyUnavailableError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedResponseType`. */
export const UnsupportedResponseTypeError = sub(OAuthErrorCode.UnsupportedResponseType, 'UnsupportedResponseTypeError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedResponseType`. */
export type UnsupportedResponseTypeError = InstanceType<typeof UnsupportedResponseTypeError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedTokenType`. */
export const UnsupportedTokenTypeError = sub(OAuthErrorCode.UnsupportedTokenType, 'UnsupportedTokenTypeError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedTokenType`. */
export type UnsupportedTokenTypeError = InstanceType<typeof UnsupportedTokenTypeError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidToken`. */
export const InvalidTokenError = sub(OAuthErrorCode.InvalidToken, 'InvalidTokenError');
Comment thread
felixweinberger marked this conversation as resolved.
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidToken`. */
export type InvalidTokenError = InstanceType<typeof InvalidTokenError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.MethodNotAllowed`. */
export const MethodNotAllowedError = sub(OAuthErrorCode.MethodNotAllowed, 'MethodNotAllowedError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.MethodNotAllowed`. */
export type MethodNotAllowedError = InstanceType<typeof MethodNotAllowedError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.TooManyRequests`. */
export const TooManyRequestsError = sub(OAuthErrorCode.TooManyRequests, 'TooManyRequestsError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.TooManyRequests`. */
export type TooManyRequestsError = InstanceType<typeof TooManyRequestsError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClientMetadata`. */
export const InvalidClientMetadataError = sub(OAuthErrorCode.InvalidClientMetadata, 'InvalidClientMetadataError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClientMetadata`. */
export type InvalidClientMetadataError = InstanceType<typeof InvalidClientMetadataError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InsufficientScope`. */
export const InsufficientScopeError = sub(OAuthErrorCode.InsufficientScope, 'InsufficientScopeError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InsufficientScope`. */
export type InsufficientScopeError = InstanceType<typeof InsufficientScopeError>;
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidTarget`. */
export const InvalidTargetError = sub(OAuthErrorCode.InvalidTarget, 'InvalidTargetError');
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidTarget`. */
export type InvalidTargetError = InstanceType<typeof InvalidTargetError>;

/**
* @deprecated Construct {@link OAuthError} directly with a custom code string.
*
* v1 pattern was `class MyErr extends CustomOAuthError { static errorCode = 'my_code' }`;
* this preserves that by reading `static errorCode` from the concrete subclass.
*/
export class CustomOAuthError extends OAuthError {
static errorCode: string;
constructor(message: string, errorUri?: string) {
super((new.target as typeof CustomOAuthError).errorCode, message, errorUri);
}
}
Comment on lines +107 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 User subclasses of CustomOAuthError get .name === 'OAuthError' instead of the subclass name — v1's base OAuthError constructor did this.name = this.constructor.name, so class MyErr extends CustomOAuthError { static errorCode = '…' } produced new MyErr('x').name === 'MyErr'. This is the same .name-fidelity gap already fixed for the 17 sub() classes in 473d2d8, but CustomOAuthError was missed; one-line fix: add this.name = new.target.name; after the super() call.

Extended reasoning...

What's the gap

v1's OAuthError base constructor (verified against src/server/auth/errors.ts @ 1.21.x) did this.name = this.constructor.name. So for the documented v1 pattern this file's JSDoc explicitly preserves —

class MyErr extends CustomOAuthError { static errorCode = 'my_code' }

— v1 produced new MyErr('x').name === 'MyErr', and err.toString() / stack traces showed MyErr: x.

In this PR, v2's OAuthError constructor (packages/core/src/auth/errors.ts:108) hardcodes this.name = 'OAuthError', and CustomOAuthError's constructor (lines 109-111) only does super((new.target as typeof CustomOAuthError).errorCode, message, errorUri) without resetting .name. So new MyErr('x').name === 'OAuthError'.

Why nothing else covers it

The 17 sub()-generated classes already handle this — line 23 sets this.name = name in each subclass constructor, and 473d2d8 added Object.defineProperty(Sub, 'name', …) for constructor.name. CustomOAuthError is the one OAuth compat class that doesn't go through sub(), and its constructor was simply not given the equivalent line. Note that CustomOAuthError is a named class declaration, so MyErr.name / err.constructor.name are already correct ('MyErr') — only the instance .name property (and therefore .toString() / stack-trace label) is wrong.

The existing test in compat.test.ts ("CustomOAuthError reads static errorCode from concrete subclass") only asserts e.code and instanceof, so it doesn't catch this.

Step-by-step proof

  1. auth/errors.ts:108 — v2 OAuthError ctor: this.name = 'OAuthError' (hardcoded, not this.constructor.name).
  2. oauthErrorsCompat.ts:109-111CustomOAuthError ctor: super((new.target as …).errorCode, message, errorUri); no this.name = … afterward.
  3. User defines class MyErr extends CustomOAuthError { static override errorCode = 'my_code' }.
  4. const e = new MyErr('nope') → super chain runs step 1 → e.name === 'OAuthError'.
  5. e.toString()'OAuthError: nope'; stack trace header reads OAuthError: nope.
  6. Under v1, step 1 instead did this.name = this.constructor.namee.name === 'MyErr', e.toString()'MyErr: nope'.

Impact

Low — only affects err.name, err.toString(), and stack-trace labeling for the niche user-defined-subclass escape hatch on a @deprecated shim. instanceof MyErr, instanceof OAuthError, .code, .errorCode, and toResponseObject() all work correctly. But it's the same v1-fidelity category the author already chose to fix for sub() in 473d2d8 (resolved thread #3099495886), and this is the sibling code path that was overlooked.

Fix

export class CustomOAuthError extends OAuthError {
    static errorCode: string;
    constructor(message: string, errorUri?: string) {
        super((new.target as typeof CustomOAuthError).errorCode, message, errorUri);
        this.name = new.target.name;
    }
}

new.target already points at the user's concrete subclass (it's used on the line above for errorCode), so new.target.name gives 'MyErr' exactly as v1's this.constructor.name did. Optionally add expect(e.name).toBe('MyError') to the existing CustomOAuthError test.

20 changes: 20 additions & 0 deletions packages/core/src/errors/streamableHttpErrorCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { SdkErrorCode } from './sdkErrors.js';
import { SdkError } from './sdkErrors.js';

/**
* @deprecated Use {@linkcode SdkError}.
*
* Subclass thrown by the StreamableHTTP client transport for HTTP-level errors.
* `instanceof StreamableHTTPError` and `instanceof SdkError` both match. Note that
* `.code` is now the {@linkcode SdkErrorCode} (a `ClientHttp*` string), not the HTTP
* status number as in v1; the status is available as `.status`.
*/
export class StreamableHTTPError extends SdkError {
Comment thread
claude[bot] marked this conversation as resolved.
public readonly status: number | undefined;

constructor(code: SdkErrorCode, message: string, data?: { status?: number } & Record<string, unknown>) {
super(code, message, data);
this.name = 'StreamableHTTPError';
this.status = data?.status;
}
}
Comment thread
felixweinberger marked this conversation as resolved.
51 changes: 51 additions & 0 deletions packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,64 @@ export { ProtocolErrorCode } from '../../types/enums.js';
// Error classes
export { ProtocolError, UrlElicitationRequiredError } from '../../types/errors.js';

// --- v1-compat aliases ---
import { SdkErrorCode as _SdkErrorCode } from '../../errors/sdkErrors.js';
import { ProtocolErrorCode as _ProtocolErrorCode } from '../../types/enums.js';
/**
* @deprecated Use {@linkcode ProtocolErrorCode} for protocol-level (wire) errors
* or {@linkcode SdkErrorCode} for local SDK errors. Note `ConnectionClosed` /
* `RequestTimeout` moved to `SdkErrorCode` in v2 and are now thrown as `SdkError`,
* not `ProtocolError`.
*/
export const ErrorCode = {
..._ProtocolErrorCode,
/** Now {@linkcode SdkErrorCode.ConnectionClosed}; thrown as `SdkError`, not `McpError`. */
ConnectionClosed: _SdkErrorCode.ConnectionClosed,
/** Now {@linkcode SdkErrorCode.RequestTimeout}; thrown as `SdkError`, not `McpError`. */
RequestTimeout: _SdkErrorCode.RequestTimeout
} as const;
/** @deprecated Use `ProtocolErrorCode` / `SdkErrorCode`. See {@linkcode ErrorCode} const. */
export type ErrorCode = _ProtocolErrorCode | typeof _SdkErrorCode.ConnectionClosed | typeof _SdkErrorCode.RequestTimeout;
export {
/** @deprecated Use {@linkcode ProtocolError} (or `SdkError` for transport-level errors). */
ProtocolError as McpError
} from '../../types/errors.js';
// Note: InvalidRequestError is intentionally omitted here — it collides with the
// JSON-RPC `InvalidRequestError` interface re-exported from types.ts. v1 users
// imported it from `server/auth/errors.js`, which the sdk meta-package subpath provides.
Comment thread
felixweinberger marked this conversation as resolved.
export {
AccessDeniedError,
CustomOAuthError,
InsufficientScopeError,
InvalidClientError,
InvalidClientMetadataError,
InvalidGrantError,
InvalidScopeError,
InvalidTargetError,
InvalidTokenError,
MethodNotAllowedError,
ServerError,
TemporarilyUnavailableError,
TooManyRequestsError,
UnauthorizedClientError,
UnsupportedGrantTypeError,
UnsupportedResponseTypeError,
UnsupportedTokenTypeError
} from '../../errors/oauthErrorsCompat.js';
export { StreamableHTTPError } from '../../errors/streamableHttpErrorCompat.js';
/** @deprecated Use {@linkcode JSONRPCErrorResponse}. */
export type { JSONRPCErrorResponse as JSONRPCError } from '../../types/spec.types.js';
// --- end v1-compat ---

// Type guards and message parsing
export {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
isCallToolResult,
isInitializedNotification,
isInitializeRequest,
/** @deprecated Use {@linkcode isJSONRPCErrorResponse}. */
isJSONRPCErrorResponse as isJSONRPCError,
isJSONRPCErrorResponse,
isJSONRPCNotification,
isJSONRPCRequest,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './auth/errors.js';
export * from './errors/sdkErrors.js';
export * from './errors/streamableHttpErrorCompat.js';
export * from './shared/auth.js';
export * from './shared/authUtils.js';
export * from './shared/metadataUtils.js';
Expand Down
Loading
Loading