diff --git a/.changeset/error-compat-aliases.md b/.changeset/error-compat-aliases.md new file mode 100644 index 000000000..0c8f02a3b --- /dev/null +++ b/.changeset/error-compat-aliases.md @@ -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). diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96d..855f59334 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -9,8 +9,8 @@ import { isJSONRPCResultResponse, JSONRPCMessageSchema, normalizeHeaders, - SdkError, - SdkErrorCode + SdkErrorCode, + StreamableHTTPError } from '@modelcontextprotocol/core'; import { EventSourceParserStream } from 'eventsource-parser/stream'; @@ -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(); } @@ -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); @@ -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(); } @@ -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 }); @@ -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 }); @@ -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 }); } @@ -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; diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index b2138b3fa..214cac2c3 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -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'; @@ -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' }) @@ -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', diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index 30c874160..594213a85 100644 --- a/packages/core/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -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. */ diff --git a/packages/core/src/errors/oauthErrorsCompat.ts b/packages/core/src/errors/oauthErrorsCompat.ts new file mode 100644 index 000000000..d51b67a33 --- /dev/null +++ b/packages/core/src/errors/oauthErrorsCompat.ts @@ -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; +} + +/* 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; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClient`. */ +export const InvalidClientError = sub(OAuthErrorCode.InvalidClient, 'InvalidClientError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClient`. */ +export type InvalidClientError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidGrant`. */ +export const InvalidGrantError = sub(OAuthErrorCode.InvalidGrant, 'InvalidGrantError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidGrant`. */ +export type InvalidGrantError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnauthorizedClient`. */ +export const UnauthorizedClientError = sub(OAuthErrorCode.UnauthorizedClient, 'UnauthorizedClientError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnauthorizedClient`. */ +export type UnauthorizedClientError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedGrantType`. */ +export const UnsupportedGrantTypeError = sub(OAuthErrorCode.UnsupportedGrantType, 'UnsupportedGrantTypeError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedGrantType`. */ +export type UnsupportedGrantTypeError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidScope`. */ +export const InvalidScopeError = sub(OAuthErrorCode.InvalidScope, 'InvalidScopeError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidScope`. */ +export type InvalidScopeError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.AccessDenied`. */ +export const AccessDeniedError = sub(OAuthErrorCode.AccessDenied, 'AccessDeniedError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.AccessDenied`. */ +export type AccessDeniedError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.ServerError`. */ +export const ServerError = sub(OAuthErrorCode.ServerError, 'ServerError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.ServerError`. */ +export type ServerError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable`. */ +export const TemporarilyUnavailableError = sub(OAuthErrorCode.TemporarilyUnavailable, 'TemporarilyUnavailableError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable`. */ +export type TemporarilyUnavailableError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedResponseType`. */ +export const UnsupportedResponseTypeError = sub(OAuthErrorCode.UnsupportedResponseType, 'UnsupportedResponseTypeError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedResponseType`. */ +export type UnsupportedResponseTypeError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedTokenType`. */ +export const UnsupportedTokenTypeError = sub(OAuthErrorCode.UnsupportedTokenType, 'UnsupportedTokenTypeError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedTokenType`. */ +export type UnsupportedTokenTypeError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidToken`. */ +export const InvalidTokenError = sub(OAuthErrorCode.InvalidToken, 'InvalidTokenError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidToken`. */ +export type InvalidTokenError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.MethodNotAllowed`. */ +export const MethodNotAllowedError = sub(OAuthErrorCode.MethodNotAllowed, 'MethodNotAllowedError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.MethodNotAllowed`. */ +export type MethodNotAllowedError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.TooManyRequests`. */ +export const TooManyRequestsError = sub(OAuthErrorCode.TooManyRequests, 'TooManyRequestsError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.TooManyRequests`. */ +export type TooManyRequestsError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClientMetadata`. */ +export const InvalidClientMetadataError = sub(OAuthErrorCode.InvalidClientMetadata, 'InvalidClientMetadataError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClientMetadata`. */ +export type InvalidClientMetadataError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InsufficientScope`. */ +export const InsufficientScopeError = sub(OAuthErrorCode.InsufficientScope, 'InsufficientScopeError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InsufficientScope`. */ +export type InsufficientScopeError = InstanceType; +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidTarget`. */ +export const InvalidTargetError = sub(OAuthErrorCode.InvalidTarget, 'InvalidTargetError'); +/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidTarget`. */ +export type InvalidTargetError = InstanceType; + +/** + * @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); + } +} diff --git a/packages/core/src/errors/streamableHttpErrorCompat.ts b/packages/core/src/errors/streamableHttpErrorCompat.ts new file mode 100644 index 000000000..959f0b3a6 --- /dev/null +++ b/packages/core/src/errors/streamableHttpErrorCompat.ts @@ -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 { + public readonly status: number | undefined; + + constructor(code: SdkErrorCode, message: string, data?: { status?: number } & Record) { + super(code, message, data); + this.name = 'StreamableHTTPError'; + this.status = data?.status; + } +} diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2dc1e13a8..126444ff4 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -100,6 +100,55 @@ 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 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'; +/** @deprecated Use {@linkcode JSONRPCErrorResponse}. */ +export type { JSONRPCErrorResponse as JSONRPCError } from '../../types/spec.types.js'; +// --- end v1-compat --- + // Type guards and message parsing export { assertCompleteRequestPrompt, @@ -107,6 +156,8 @@ export { isCallToolResult, isInitializedNotification, isInitializeRequest, + /** @deprecated Use {@linkcode isJSONRPCErrorResponse}. */ + isJSONRPCErrorResponse as isJSONRPCError, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e707d9939..54fac282d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/test/errors/compat.test.ts b/packages/core/test/errors/compat.test.ts new file mode 100644 index 000000000..b5b2bc051 --- /dev/null +++ b/packages/core/test/errors/compat.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CustomOAuthError, + ErrorCode, + InvalidTokenError, + McpError, + OAuthError, + OAuthErrorCode, + ProtocolError, + ProtocolErrorCode, + SdkError, + SdkErrorCode, + ServerError, + StreamableHTTPError +} from '../../src/exports/public/index.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('OAuth subclass class .name matches v1 named-declaration behavior', () => { + expect(InvalidTokenError.name).toBe('InvalidTokenError'); + const e = new InvalidTokenError('expired'); + expect(e.name).toBe('InvalidTokenError'); + expect(e.constructor.name).toBe('InvalidTokenError'); + }); + + it('OAuth subclasses are usable in type position (value + type binding like v1 classes)', () => { + const e: InvalidTokenError = new InvalidTokenError('expired'); + const handle = (err: ServerError): string => err.code; + expect(handle(new ServerError('boom'))).toBe('server_error'); + expect(e.code).toBe('invalid_token'); + }); + + 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'); + }); +});