From 676bf18ad0902d10ebebf968fca096401408a4d5 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Mon, 11 May 2026 18:39:19 +0300 Subject: [PATCH 1/6] backwards compat: SdkError codes, introduce SdkHttpError --- packages/client/src/client/sse.ts | 4 +- packages/client/src/client/streamableHttp.ts | 15 +++---- packages/client/test/client/sse.test.ts | 7 ++-- .../client/test/client/streamableHttp.test.ts | 9 ++-- .../client/test/client/tokenProvider.test.ts | 7 ++-- packages/core/src/errors/sdkErrors.ts | 42 +++++++++++++++++++ packages/core/src/exports/public/index.ts | 3 +- 7 files changed, 67 insertions(+), 20 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index f441e9cdb..c5685a1b6 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,5 +1,5 @@ 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,7 +286,7 @@ export class SSEClientTransport implements Transport { } await response.text?.().catch(() => {}); if (isAuthRetry) { - throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { status: 401 }); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96d..513acdb95 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,7 +274,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 SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { status: 401 }); } @@ -288,7 +289,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,7 +582,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 SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { status: 401 }); } @@ -598,7 +599,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 SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { status: 403, text }); @@ -629,7 +630,7 @@ 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, text }); @@ -725,7 +726,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 SdkHttpError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, { status: response.status, statusText: response.statusText }); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index b0b9588f0..62ce70498 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, SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import type { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; @@ -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 b2138b3fa..68e7a9496 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, SdkHttpError } 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 SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', { status: 404, text: 'Session not found' }) @@ -1871,8 +1871,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 d6ef35bde..3c7ab2cb3 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 { SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import type { Mock } from 'vitest'; @@ -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.ts b/packages/core/src/errors/sdkErrors.ts index 8d5e34c14..b45d1e495 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 + * 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 5c1689ca6..9c0561709 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 { SdkError, SdkErrorCode, SdkHttpError } from '../../errors/sdkErrors.js'; +export type { SdkHttpErrorData } from '../../errors/sdkErrors.js'; // Auth TypeScript types (NOT Zod schemas like OAuthMetadataSchema) export type { From 604f95a406bc58e8c30e85786601ccdaeea9b191 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Mon, 11 May 2026 18:41:52 +0300 Subject: [PATCH 2/6] lint fix --- packages/client/src/client/sse.ts | 9 ++++++++- packages/client/src/client/streamableHttp.ts | 12 ++++++++---- packages/core/src/exports/public/index.ts | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index c5685a1b6..ad86df57a 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, SdkHttpError } from '@modelcontextprotocol/core'; +import { + createFetchWithInit, + JSONRPCMessageSchema, + normalizeHeaders, + SdkError, + SdkErrorCode, + SdkHttpError +} from '@modelcontextprotocol/core'; import type { ErrorEvent, EventSourceInit } from 'eventsource'; import { EventSource } from 'eventsource'; diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 513acdb95..0b8fef9c6 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -726,10 +726,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 SdkHttpError(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/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 9c0561709..31ade0dbc 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -13,8 +13,8 @@ export { OAuthError, OAuthErrorCode } from '../../auth/errors.js'; // SDK error types (local errors that never cross the wire) -export { SdkError, SdkErrorCode, SdkHttpError } 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 { From 37b8c37a86a6af21ba593297f5051d9e6f724566 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 15 May 2026 13:16:40 +0300 Subject: [PATCH 3/6] review fixes --- .changeset/add-sdk-http-error.md | 6 ++++++ packages/client/test/client/sse.test.ts | 4 ++-- packages/client/test/client/streamableHttp.test.ts | 2 +- packages/client/test/client/tokenProvider.test.ts | 4 ++-- packages/core/src/errors/sdkErrors.examples.ts | 14 +++++++++++++- packages/core/src/errors/sdkErrors.ts | 6 +++--- 6 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-sdk-http-error.md diff --git a/.changeset/add-sdk-http-error.md b/.changeset/add-sdk-http-error.md new file mode 100644 index 000000000..79ddc1792 --- /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` and `SSEClientTransport` now throw `SdkHttpError` (which extends `SdkError`) for non-OK HTTP responses. diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 62ce70498..6948d9a4e 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, SdkHttpError } 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(); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 68e7a9496..802eb9ad4 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, SdkHttpError } 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'; diff --git a/packages/client/test/client/tokenProvider.test.ts b/packages/client/test/client/tokenProvider.test.ts index 3c7ab2cb3..e1108267e 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, SdkHttpError } 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 () => {}) diff --git a/packages/core/src/errors/sdkErrors.examples.ts b/packages/core/src/errors/sdkErrors.examples.ts index 729a879ac..d80fd6e75 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 b45d1e495..af432c638 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -85,10 +85,10 @@ export interface SdkHttpErrorData { * without unsafe casting. * * @example - * ```ts + * ```ts source="./sdkErrors.examples.ts#SdkHttpError_basicUsage" * if (error instanceof SdkHttpError) { - * console.log(error.status); // number - * console.log(error.statusText); // string | undefined + * console.log(error.status); // number + * console.log(error.statusText); // string | undefined * } * ``` */ From 4932aae3be44ca7e85a3b76d82b2031c0d1aaeba Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 15 May 2026 13:36:18 +0300 Subject: [PATCH 4/6] review fixes --- .changeset/add-sdk-http-error.md | 2 +- docs/migration-SKILL.md | 22 ++++++++++++---------- docs/migration.md | 13 +++++++------ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.changeset/add-sdk-http-error.md b/.changeset/add-sdk-http-error.md index 79ddc1792..c3331a565 100644 --- a/.changeset/add-sdk-http-error.md +++ b/.changeset/add-sdk-http-error.md @@ -3,4 +3,4 @@ "@modelcontextprotocol/client": minor --- -Add `SdkHttpError` subclass with typed `.status` / `.statusText` accessors for HTTP transport failures. `StreamableHTTPClientTransport` and `SSEClientTransport` now throw `SdkHttpError` (which extends `SdkError`) for non-OK HTTP responses. +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 9cff719bb..f846fd3dc 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,10 @@ 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 } ``` diff --git a/docs/migration.md b/docs/migration.md index fecf18599..ddcb53f38 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; } } ``` From d9a904fafe3185ac53728cedc6675d8903d61aa6 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 15 May 2026 13:56:33 +0300 Subject: [PATCH 5/6] fix docs --- docs/migration-SKILL.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index f846fd3dc..ba99203e6 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -166,6 +166,13 @@ 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; + } } ``` From bfdca8644b6eeaeacd3473f9799df086fb4de024 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 15 May 2026 15:14:49 +0300 Subject: [PATCH 6/6] fix nit --- packages/client/src/client/sse.ts | 3 ++- packages/client/src/client/streamableHttp.ts | 8 ++++++-- packages/client/test/client/streamableHttp.test.ts | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index ad86df57a..bf554aba2 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -294,7 +294,8 @@ export class SSEClientTransport implements Transport { await response.text?.().catch(() => {}); if (isAuthRetry) { throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { - status: 401 + 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 0b8fef9c6..3b8ddafe5 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -275,7 +275,8 @@ export class StreamableHTTPClientTransport implements Transport { await response.text?.().catch(() => {}); if (isAuthRetry) { throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { - status: 401 + status: 401, + statusText: response.statusText }); } throw new UnauthorizedError(); @@ -583,7 +584,8 @@ export class StreamableHTTPClientTransport implements Transport { await response.text?.().catch(() => {}); if (isAuthRetry) { throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { - status: 401 + status: 401, + statusText: response.statusText }); } throw new UnauthorizedError(); @@ -601,6 +603,7 @@ export class StreamableHTTPClientTransport implements Transport { if (this._lastUpscopingHeader === wwwAuthHeader) { throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { status: 403, + statusText: response.statusText, text }); } @@ -632,6 +635,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, + statusText: response.statusText, text }); } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 802eb9ad4..0edf8b75a 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -242,6 +242,7 @@ describe('StreamableHTTPClientTransport', () => { await expect(transport.send(message)).rejects.toThrow( new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', { status: 404, + statusText: 'Not Found', text: 'Session not found' }) );