diff --git a/.changeset/error-compat-aliases.md b/.changeset/error-compat-aliases.md new file mode 100644 index 000000000..a1bf31054 --- /dev/null +++ b/.changeset/error-compat-aliases.md @@ -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`). 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/src/validators/cfWorker.ts b/packages/client/src/validators/cfWorker.ts index b068e69a1..7d1c843e5 100644 --- a/packages/client/src/validators/cfWorker.ts +++ b/packages/client/src/validators/cfWorker.ts @@ -6,5 +6,5 @@ * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/client/validators/cf-worker'; * ``` */ -export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index b2138b3fa..19290f7b0 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' }) diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index 30c874160..61ae975a5 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'; } + /** + * v1 alias for {@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..6a5fb4ae9 --- /dev/null +++ b/packages/core/src/errors/oauthErrorsCompat.ts @@ -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); + } +} diff --git a/packages/core/src/errors/streamableHttpErrorCompat.ts b/packages/core/src/errors/streamableHttpErrorCompat.ts new file mode 100644 index 000000000..ece79b34c --- /dev/null +++ b/packages/core/src/errors/streamableHttpErrorCompat.ts @@ -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) { + 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..9bd8e5d65 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'; +/** + * 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, @@ -107,6 +156,8 @@ export { isCallToolResult, isInitializedNotification, isInitializeRequest, + /** v1 name. v2 also exposes this as {@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..7855c4fc7 --- /dev/null +++ b/packages/core/test/errors/compat.test.ts @@ -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'); + }); +});