diff --git a/.changeset/add-sdk-http-error.md b/.changeset/add-sdk-http-error.md new file mode 100644 index 0000000000..c3331a565e --- /dev/null +++ b/.changeset/add-sdk-http-error.md @@ -0,0 +1,6 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/client": minor +--- + +Add `SdkHttpError` subclass with typed `.status` / `.statusText` accessors for HTTP transport failures. `StreamableHTTPClientTransport` now throws `SdkHttpError` (which extends `SdkError`) for non-OK HTTP responses; `SSEClientTransport` throws `SdkHttpError` for 401-after-reauth (circuit breaker). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 9cff719bbc..ba99203e6a 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -95,7 +95,7 @@ Notes: | `ErrorCode` | `ProtocolErrorCode` | | `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | | `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | -| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | +| `StreamableHTTPError` | REMOVED (use `SdkHttpError` with `SdkErrorCode.ClientHttp*`) | | `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names. **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) are no longer part of the public API — they are internal to the SDK. For runtime validation, use @@ -103,10 +103,11 @@ All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain thei ### Error class changes -Two error classes now exist: +Three error classes now exist: - **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC responses - **`SdkError`** (new): Local SDK errors that never cross the wire +- **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors | Error scenario | v1 type | v2 type | | --------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- | @@ -115,12 +116,12 @@ Two error classes now exist: | Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | | Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | | Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttp*` | -| Failed to open SSE stream | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | -| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpAuthentication` | -| 403 after upscoping | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpForbidden` | +| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | +| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | +| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | +| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | | Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` | -| Session termination failed | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | +| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | | Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | New `SdkErrorCode` enum values: @@ -161,9 +162,17 @@ if (error instanceof StreamableHTTPError) { } // v2 -import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpFailedToOpenStream) { - const status = (error.data as { status?: number })?.status; +import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof SdkHttpError) { + console.log('HTTP status:', error.status); // number — typed accessor + console.log('Status text:', error.statusText); // string | undefined + switch (error.code) { + case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth + case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping + case SdkErrorCode.ClientHttpFailedToOpenStream: + case SdkErrorCode.ClientHttpNotImplemented: + break; + } } ``` diff --git a/docs/migration.md b/docs/migration.md index fecf185996..ddcb53f38d 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -652,10 +652,11 @@ These replace the pattern of calling `server.sendLoggingMessage()`, `server.crea ### Error hierarchy refactoring -The SDK now distinguishes between two types of errors: +The SDK now distinguishes between three types of errors: 1. **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC error responses 2. **`SdkError`**: Local SDK errors that never cross the wire (timeouts, connection issues, capability checks) +3. **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors #### Renamed exports @@ -725,7 +726,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors: #### `StreamableHTTPError` removed -The `StreamableHTTPError` class has been removed. HTTP transport errors are now thrown as `SdkError` with specific `SdkErrorCode` values that provide more granular error information: +The `StreamableHTTPError` class has been removed. HTTP transport errors are now thrown as `SdkHttpError` (a subclass of `SdkError` with typed `.status` and `.statusText` accessors) with specific `SdkErrorCode` values that provide more granular error information: **Before (v1):** @@ -744,12 +745,14 @@ try { **After (v2):** ```typescript -import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; +import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; try { await transport.send(message); } catch (error) { - if (error instanceof SdkError) { + if (error instanceof SdkHttpError) { + console.log('HTTP status:', error.status); // number — no cast needed + console.log('Status text:', error.statusText); // string | undefined switch (error.code) { case SdkErrorCode.ClientHttpAuthentication: console.log('Auth failed — server rejected token after re-auth'); @@ -764,8 +767,6 @@ try { console.log('HTTP request failed'); break; } - // Access HTTP status code from error.data if needed - const httpStatus = (error.data as { status?: number })?.status; } } ``` diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index f441e9cdb8..bf554aba29 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,5 +1,12 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; -import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { + createFetchWithInit, + JSONRPCMessageSchema, + normalizeHeaders, + SdkError, + SdkErrorCode, + SdkHttpError +} from '@modelcontextprotocol/core'; import type { ErrorEvent, EventSourceInit } from 'eventsource'; import { EventSource } from 'eventsource'; @@ -286,8 +293,9 @@ export class SSEClientTransport implements Transport { } await response.text?.().catch(() => {}); if (isAuthRetry) { - throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { - status: 401 + throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: response.statusText }); } throw new UnauthorizedError(); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96dc..3b8ddafe5a 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -10,7 +10,8 @@ import { JSONRPCMessageSchema, normalizeHeaders, SdkError, - SdkErrorCode + SdkErrorCode, + SdkHttpError } from '@modelcontextprotocol/core'; import { EventSourceParserStream } from 'eventsource-parser/stream'; @@ -273,8 +274,9 @@ 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 SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: response.statusText }); } throw new UnauthorizedError(); @@ -288,7 +290,7 @@ export class StreamableHTTPClientTransport implements Transport { return; } - throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { + throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { status: response.status, statusText: response.statusText }); @@ -581,8 +583,9 @@ 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 SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: response.statusText }); } throw new UnauthorizedError(); @@ -598,8 +601,9 @@ 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 SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { status: 403, + statusText: response.statusText, text }); } @@ -629,8 +633,9 @@ export class StreamableHTTPClientTransport implements Transport { } } - throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, + statusText: response.statusText, text }); } @@ -725,10 +730,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 SdkHttpError( + SdkErrorCode.ClientHttpFailedToTerminateSession, + `Failed to terminate session: ${response.statusText}`, + { + status: response.status, + statusText: response.statusText + } + ); } this._sessionId = undefined; diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index b0b9588f02..6948d9a4e0 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -3,7 +3,7 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import type { JSONRPCMessage, OAuthTokens } from '@modelcontextprotocol/core'; -import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import type { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; @@ -1575,7 +1575,7 @@ describe('SSEClientTransport', () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); }); - it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkError', async () => { + it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkHttpError', async () => { postResponses = [401, 401]; await setupServer(); @@ -1587,8 +1587,9 @@ describe('SSEClientTransport', () => { await transport.start(); const error = await transport.send(message).catch(e => e); - expect(error).toBeInstanceOf(SdkError); - expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); expect(postCount).toBe(2); }); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index b2138b3fa8..0edf8b75ac 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, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; import type { Mock, Mocked } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth.js'; @@ -240,8 +240,9 @@ describe('StreamableHTTPClientTransport', () => { transport.onerror = errorSpy; await expect(transport.send(message)).rejects.toThrow( - new SdkError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', { + new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', { status: 404, + statusText: 'Not Found', text: 'Session not found' }) ); @@ -1871,8 +1872,9 @@ describe('StreamableHTTPClientTransport', () => { .mockResolvedValueOnce(unauthedResponse); const error = await transport.send(message).catch(e => e); - expect(error).toBeInstanceOf(SdkError); - expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ access_token: 'new-access-token', token_type: 'Bearer', diff --git a/packages/client/test/client/tokenProvider.test.ts b/packages/client/test/client/tokenProvider.test.ts index d6ef35bdee..e1108267ef 100644 --- a/packages/client/test/client/tokenProvider.test.ts +++ b/packages/client/test/client/tokenProvider.test.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, Server } from 'node:http'; import { createServer } from 'node:http'; import type { JSONRPCMessage, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; -import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import type { Mock } from 'vitest'; @@ -86,7 +86,7 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => { expect(retryInit.headers.get('Authorization')).toBe('Bearer new-token'); }); - it('should throw SdkError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => { + it('should throw SdkHttpError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => { const authProvider: AuthProvider = { token: vi.fn(async () => 'still-bad'), onUnauthorized: vi.fn(async () => {}) @@ -99,8 +99,9 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => { .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }); const error = await transport.send(message).catch(e => e); - expect(error).toBeInstanceOf(SdkError); - expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/errors/sdkErrors.examples.ts b/packages/core/src/errors/sdkErrors.examples.ts index 729a879acd..d80fd6e756 100644 --- a/packages/core/src/errors/sdkErrors.examples.ts +++ b/packages/core/src/errors/sdkErrors.examples.ts @@ -7,7 +7,7 @@ * @module */ -import { SdkError, SdkErrorCode } from './sdkErrors.js'; +import { SdkError, SdkErrorCode, SdkHttpError } from './sdkErrors.js'; /** * Example: Throwing and catching SDK errors. @@ -25,3 +25,15 @@ function SdkError_basicUsage() { } //#endregion SdkError_basicUsage } + +/** + * Example: Checking for HTTP transport errors. + */ +function SdkHttpError_basicUsage(error: unknown) { + //#region SdkHttpError_basicUsage + if (error instanceof SdkHttpError) { + console.log(error.status); // number + console.log(error.statusText); // string | undefined + } + //#endregion SdkHttpError_basicUsage +} diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 8d5e34c14e..af432c6389 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -66,3 +66,45 @@ export class SdkError extends Error { this.name = 'SdkError'; } } + +/** + * Typed shape for HTTP error data carried by {@linkcode SdkHttpError}. + */ +export interface SdkHttpErrorData { + status: number; + statusText?: string; + [key: string]: unknown; +} + +/** + * An {@linkcode SdkError} subclass for HTTP transport failures. + * + * Thrown by the streamable HTTP transport when the server responds with a + * non-OK status code. Narrows {@linkcode SdkError.data | data} to + * {@linkcode SdkHttpErrorData} so consumers can inspect the HTTP status + * without unsafe casting. + * + * @example + * ```ts source="./sdkErrors.examples.ts#SdkHttpError_basicUsage" + * if (error instanceof SdkHttpError) { + * console.log(error.status); // number + * console.log(error.statusText); // string | undefined + * } + * ``` + */ +export class SdkHttpError extends SdkError { + declare readonly data: SdkHttpErrorData; + + constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) { + super(code, message, data); + this.name = 'SdkHttpError'; + } + + get status(): number { + return this.data.status; + } + + get statusText(): string | undefined { + return this.data.statusText; + } +} diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 5c1689ca60..31ade0dbc8 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -13,7 +13,8 @@ export { OAuthError, OAuthErrorCode } from '../../auth/errors.js'; // SDK error types (local errors that never cross the wire) -export { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; +export type { SdkHttpErrorData } from '../../errors/sdkErrors.js'; +export { SdkError, SdkErrorCode, SdkHttpError } from '../../errors/sdkErrors.js'; // Auth TypeScript types (NOT Zod schemas like OAuthMetadataSchema) export type {