Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/error-compat-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/core': patch
---

Add v1-compat error aliases: `McpError`/`ErrorCode` (alias `ProtocolError`/`ProtocolErrorCode`, with `ConnectionClosed`/`RequestTimeout` from `SdkErrorCode`), `OAuthError.errorCode` getter (alias `.code`), `JSONRPCError`/`isJSONRPCError`, the 17 v1 OAuth error subclasses (`InvalidTokenError`, `ServerError`, …) as thin wrappers around `OAuthError` + `OAuthErrorCode`, and `StreamableHTTPError` (construct-only `@deprecated` shim; v2 throws `SdkError`).
18 changes: 9 additions & 9 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,7 +273,7 @@ export class StreamableHTTPClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
});
}
Expand All @@ -288,7 +288,7 @@ export class StreamableHTTPClientTransport implements Transport {
return;
}

throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
Expand Down Expand Up @@ -581,7 +581,7 @@ export class StreamableHTTPClientTransport implements Transport {
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
status: 401
});
}
Expand All @@ -598,7 +598,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 +629,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 +675,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,7 +725,7 @@ 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}`, {
throw new StreamableHTTPError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
Expand Down
4 changes: 2 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
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';
}

/**
* v1 alias for {@linkcode OAuthError.code | .code}.
*/
get errorCode(): string {
return this.code;
}

/**
* Converts the error to a standard OAuth error response object.
*/
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/errors/oauthErrorsCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* 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;
/** v1 static field. v2-preferred is the instance `.code` property. */
errorCode: string;
};

function sub(code: OAuthErrorCode, name: string): OAuthErrorSubclass {
return class extends OAuthError {
static errorCode = code as string;
constructor(message: string, errorUri?: string) {
super(code, message, errorUri);
this.name = name;
}
};
}

/* eslint-disable @typescript-eslint/naming-convention */

/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidRequest, ...)`. */
export const InvalidRequestError = sub(OAuthErrorCode.InvalidRequest, 'InvalidRequestError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidClient, ...)`. */
export const InvalidClientError = sub(OAuthErrorCode.InvalidClient, 'InvalidClientError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidGrant, ...)`. */
export const InvalidGrantError = sub(OAuthErrorCode.InvalidGrant, 'InvalidGrantError');
/** Equivalent to `new OAuthError(OAuthErrorCode.UnauthorizedClient, ...)`. */
export const UnauthorizedClientError = sub(OAuthErrorCode.UnauthorizedClient, 'UnauthorizedClientError');
/** Equivalent to `new OAuthError(OAuthErrorCode.UnsupportedGrantType, ...)`. */
export const UnsupportedGrantTypeError = sub(OAuthErrorCode.UnsupportedGrantType, 'UnsupportedGrantTypeError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidScope, ...)`. */
export const InvalidScopeError = sub(OAuthErrorCode.InvalidScope, 'InvalidScopeError');
/** Equivalent to `new OAuthError(OAuthErrorCode.AccessDenied, ...)`. */
export const AccessDeniedError = sub(OAuthErrorCode.AccessDenied, 'AccessDeniedError');
/** Equivalent to `new OAuthError(OAuthErrorCode.ServerError, ...)`. */
export const ServerError = sub(OAuthErrorCode.ServerError, 'ServerError');
/** Equivalent to `new OAuthError(OAuthErrorCode.TemporarilyUnavailable, ...)`. */
export const TemporarilyUnavailableError = sub(OAuthErrorCode.TemporarilyUnavailable, 'TemporarilyUnavailableError');
/** Equivalent to `new OAuthError(OAuthErrorCode.UnsupportedResponseType, ...)`. */
export const UnsupportedResponseTypeError = sub(OAuthErrorCode.UnsupportedResponseType, 'UnsupportedResponseTypeError');
/** Equivalent to `new OAuthError(OAuthErrorCode.UnsupportedTokenType, ...)`. */
export const UnsupportedTokenTypeError = sub(OAuthErrorCode.UnsupportedTokenType, 'UnsupportedTokenTypeError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidToken, ...)`. */
export const InvalidTokenError = sub(OAuthErrorCode.InvalidToken, 'InvalidTokenError');
/** Equivalent to `new OAuthError(OAuthErrorCode.MethodNotAllowed, ...)`. */
export const MethodNotAllowedError = sub(OAuthErrorCode.MethodNotAllowed, 'MethodNotAllowedError');
/** Equivalent to `new OAuthError(OAuthErrorCode.TooManyRequests, ...)`. */
export const TooManyRequestsError = sub(OAuthErrorCode.TooManyRequests, 'TooManyRequestsError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidClientMetadata, ...)`. */
export const InvalidClientMetadataError = sub(OAuthErrorCode.InvalidClientMetadata, 'InvalidClientMetadataError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InsufficientScope, ...)`. */
export const InsufficientScopeError = sub(OAuthErrorCode.InsufficientScope, 'InsufficientScopeError');
/** Equivalent to `new OAuthError(OAuthErrorCode.InvalidTarget, ...)`. */
export const InvalidTargetError = sub(OAuthErrorCode.InvalidTarget, 'InvalidTargetError');

/**
* v1 base class for custom OAuth error codes.
*
* v1 pattern was `class MyErr extends CustomOAuthError { static errorCode = 'my_code' }`;
* this preserves that by reading `static errorCode` from the concrete subclass.
* v2-preferred is to construct {@link OAuthError} directly with a custom code string.
*/
export class CustomOAuthError extends OAuthError {
static errorCode: string;
constructor(message: string, errorUri?: string) {
super((new.target as typeof CustomOAuthError).errorCode, message, errorUri);
}
}
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';

/**
* Subclass of {@linkcode SdkError} thrown by the StreamableHTTP client transport for
* HTTP-level errors. v1 name; the v2-preferred class is `SdkError`.
*
* `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 {
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;
}
}
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 @@ -100,13 +100,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';
/**
* v1 name. v2 also exposes the underlying split: {@linkcode ProtocolErrorCode} for
* protocol-level (wire) errors and {@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;
/** See {@linkcode ErrorCode} const. */
export type ErrorCode = _ProtocolErrorCode | typeof _SdkErrorCode.ConnectionClosed | typeof _SdkErrorCode.RequestTimeout;
export {
/** v1 name. v2 also exposes this as {@linkcode ProtocolError}. */
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 below. v1 users
// imported it from `server/auth/errors.js`, which the sdk meta-package subpath provides.
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';
/** v1 name. v2 also exposes this as {@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,
/** v1 name. v2 also exposes this as {@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
68 changes: 68 additions & 0 deletions packages/core/test/errors/compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import {
ErrorCode,
InvalidTokenError,
McpError,
OAuthError,
OAuthErrorCode,
ProtocolError,
ProtocolErrorCode,
SdkError,
SdkErrorCode,
StreamableHTTPError
} from '../../src/exports/public/index.js';
import { CustomOAuthError, ServerError } from '../../src/errors/oauthErrorsCompat.js';

describe('v1-compat error aliases', () => {
it('McpError / ErrorCode alias ProtocolError / ProtocolErrorCode (+ ConnectionClosed/RequestTimeout from SdkErrorCode)', () => {
expect(McpError).toBe(ProtocolError);
expect(ErrorCode.InvalidParams).toBe(ProtocolErrorCode.InvalidParams);
expect(ErrorCode.ConnectionClosed).toBe(SdkErrorCode.ConnectionClosed);
expect(ErrorCode.RequestTimeout).toBe(SdkErrorCode.RequestTimeout);
const e = new McpError(ErrorCode.InvalidParams, 'x');
expect(e).toBeInstanceOf(ProtocolError);
expect(e.code).toBe(ProtocolErrorCode.InvalidParams);
});

it('OAuthError.errorCode getter returns .code (no warning)', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const e = new OAuthError(OAuthErrorCode.InvalidToken, 'bad');
expect(e.errorCode).toBe(OAuthErrorCode.InvalidToken);
expect(e.errorCode).toBe('invalid_token');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('InvalidTokenError is an OAuthError with .code = InvalidToken (no warning)', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const e = new InvalidTokenError('expired');
expect(e).toBeInstanceOf(OAuthError);
expect(e.code).toBe(OAuthErrorCode.InvalidToken);
expect(e.message).toBe('expired');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('subclass static errorCode and toResponseObject() match v1 wire format', () => {
expect(ServerError.errorCode).toBe('server_error');
const e = new ServerError('boom');
expect(e.toResponseObject()).toEqual({ error: 'server_error', error_description: 'boom' });
});

it('CustomOAuthError reads static errorCode from concrete subclass', () => {
class MyError extends CustomOAuthError {
static override errorCode = 'my_custom_code';
}
const e = new MyError('nope');
expect(e).toBeInstanceOf(OAuthError);
expect(e.code).toBe('my_custom_code');
});

it('StreamableHTTPError is an SdkError subclass with .status from data', () => {
const e = new StreamableHTTPError(SdkErrorCode.ClientHttpFailedToOpenStream, 'Service Unavailable', { status: 503 });
expect(e).toBeInstanceOf(SdkError);
expect(e.code).toBe(SdkErrorCode.ClientHttpFailedToOpenStream);
expect(e.status).toBe(503);
expect(e.name).toBe('StreamableHTTPError');
});
});
Loading