Skip to content

Commit 4f226c1

Browse files
v2 backwards compat: SdkError status code (modelcontextprotocol#2049)
1 parent 22595b9 commit 4f226c1

11 files changed

Lines changed: 137 additions & 45 deletions

File tree

.changeset/add-sdk-http-error.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@modelcontextprotocol/core": minor
3+
"@modelcontextprotocol/client": minor
4+
---
5+
6+
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).

docs/migration-SKILL.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,19 @@ Notes:
9595
| `ErrorCode` | `ProtocolErrorCode` |
9696
| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` |
9797
| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` |
98-
| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) |
98+
| `StreamableHTTPError` | REMOVED (use `SdkHttpError` with `SdkErrorCode.ClientHttp*`) |
9999
| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) |
100100

101101
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
102102
`isSpecType.TypeName(value)` (e.g., `isSpecType.CallToolResult(v)`) or `specTypeSchemas.TypeName` for the `StandardSchemaV1Sync` validator object. The keys are typed as `SpecTypeName`, a literal union of all spec type names.
103103

104104
### Error class changes
105105

106-
Two error classes now exist:
106+
Three error classes now exist:
107107

108108
- **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC responses
109109
- **`SdkError`** (new): Local SDK errors that never cross the wire
110+
- **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors
110111

111112
| Error scenario | v1 type | v2 type |
112113
| --------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- |
@@ -115,12 +116,12 @@ Two error classes now exist:
115116
| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` |
116117
| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` |
117118
| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` |
118-
| HTTP transport error | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttp*` |
119-
| Failed to open SSE stream | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpFailedToOpenStream` |
120-
| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpAuthentication` |
121-
| 403 after upscoping | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpForbidden` |
119+
| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` |
120+
| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` |
121+
| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` |
122+
| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
122123
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
123-
| Session termination failed | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
124+
| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
124125
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |
125126

126127
New `SdkErrorCode` enum values:
@@ -161,9 +162,17 @@ if (error instanceof StreamableHTTPError) {
161162
}
162163

163164
// v2
164-
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
165-
if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpFailedToOpenStream) {
166-
const status = (error.data as { status?: number })?.status;
165+
import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client';
166+
if (error instanceof SdkHttpError) {
167+
console.log('HTTP status:', error.status); // number — typed accessor
168+
console.log('Status text:', error.statusText); // string | undefined
169+
switch (error.code) {
170+
case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth
171+
case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping
172+
case SdkErrorCode.ClientHttpFailedToOpenStream:
173+
case SdkErrorCode.ClientHttpNotImplemented:
174+
break;
175+
}
167176
}
168177
```
169178

docs/migration.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -652,10 +652,11 @@ These replace the pattern of calling `server.sendLoggingMessage()`, `server.crea
652652

653653
### Error hierarchy refactoring
654654

655-
The SDK now distinguishes between two types of errors:
655+
The SDK now distinguishes between three types of errors:
656656

657657
1. **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC error responses
658658
2. **`SdkError`**: Local SDK errors that never cross the wire (timeouts, connection issues, capability checks)
659+
3. **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors
659660

660661
#### Renamed exports
661662

@@ -725,7 +726,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
725726

726727
#### `StreamableHTTPError` removed
727728

728-
The `StreamableHTTPError` class has been removed. HTTP transport errors are now thrown as `SdkError` with specific `SdkErrorCode` values that provide more granular error information:
729+
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:
729730

730731
**Before (v1):**
731732

@@ -744,12 +745,14 @@ try {
744745
**After (v2):**
745746

746747
```typescript
747-
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
748+
import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client';
748749

749750
try {
750751
await transport.send(message);
751752
} catch (error) {
752-
if (error instanceof SdkError) {
753+
if (error instanceof SdkHttpError) {
754+
console.log('HTTP status:', error.status); // number — no cast needed
755+
console.log('Status text:', error.statusText); // string | undefined
753756
switch (error.code) {
754757
case SdkErrorCode.ClientHttpAuthentication:
755758
console.log('Auth failed — server rejected token after re-auth');
@@ -764,8 +767,6 @@ try {
764767
console.log('HTTP request failed');
765768
break;
766769
}
767-
// Access HTTP status code from error.data if needed
768-
const httpStatus = (error.data as { status?: number })?.status;
769770
}
770771
}
771772
```

packages/client/src/client/sse.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
2-
import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
2+
import {
3+
createFetchWithInit,
4+
JSONRPCMessageSchema,
5+
normalizeHeaders,
6+
SdkError,
7+
SdkErrorCode,
8+
SdkHttpError
9+
} from '@modelcontextprotocol/core';
310
import type { ErrorEvent, EventSourceInit } from 'eventsource';
411
import { EventSource } from 'eventsource';
512

@@ -286,8 +293,9 @@ export class SSEClientTransport implements Transport {
286293
}
287294
await response.text?.().catch(() => {});
288295
if (isAuthRetry) {
289-
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
290-
status: 401
296+
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
297+
status: 401,
298+
statusText: response.statusText
291299
});
292300
}
293301
throw new UnauthorizedError();

packages/client/src/client/streamableHttp.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
JSONRPCMessageSchema,
1111
normalizeHeaders,
1212
SdkError,
13-
SdkErrorCode
13+
SdkErrorCode,
14+
SdkHttpError
1415
} from '@modelcontextprotocol/core';
1516
import { EventSourceParserStream } from 'eventsource-parser/stream';
1617

@@ -273,8 +274,9 @@ export class StreamableHTTPClientTransport implements Transport {
273274
}
274275
await response.text?.().catch(() => {});
275276
if (isAuthRetry) {
276-
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
277-
status: 401
277+
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
278+
status: 401,
279+
statusText: response.statusText
278280
});
279281
}
280282
throw new UnauthorizedError();
@@ -288,7 +290,7 @@ export class StreamableHTTPClientTransport implements Transport {
288290
return;
289291
}
290292

291-
throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
293+
throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
292294
status: response.status,
293295
statusText: response.statusText
294296
});
@@ -581,8 +583,9 @@ export class StreamableHTTPClientTransport implements Transport {
581583
}
582584
await response.text?.().catch(() => {});
583585
if (isAuthRetry) {
584-
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
585-
status: 401
586+
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
587+
status: 401,
588+
statusText: response.statusText
586589
});
587590
}
588591
throw new UnauthorizedError();
@@ -598,8 +601,9 @@ export class StreamableHTTPClientTransport implements Transport {
598601

599602
// Check if we've already tried upscoping with this header to prevent infinite loops.
600603
if (this._lastUpscopingHeader === wwwAuthHeader) {
601-
throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
604+
throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
602605
status: 403,
606+
statusText: response.statusText,
603607
text
604608
});
605609
}
@@ -629,8 +633,9 @@ export class StreamableHTTPClientTransport implements Transport {
629633
}
630634
}
631635

632-
throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
636+
throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
633637
status: response.status,
638+
statusText: response.statusText,
634639
text
635640
});
636641
}
@@ -725,10 +730,14 @@ export class StreamableHTTPClientTransport implements Transport {
725730
// We specifically handle 405 as a valid response according to the spec,
726731
// meaning the server does not support explicit session termination
727732
if (!response.ok && response.status !== 405) {
728-
throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, {
729-
status: response.status,
730-
statusText: response.statusText
731-
});
733+
throw new SdkHttpError(
734+
SdkErrorCode.ClientHttpFailedToTerminateSession,
735+
`Failed to terminate session: ${response.statusText}`,
736+
{
737+
status: response.status,
738+
statusText: response.statusText
739+
}
740+
);
732741
}
733742

734743
this._sessionId = undefined;

packages/client/test/client/sse.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createServer } from 'node:http';
33
import type { AddressInfo } from 'node:net';
44

55
import type { JSONRPCMessage, OAuthTokens } from '@modelcontextprotocol/core';
6-
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
6+
import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
77
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
88
import type { Mock, Mocked, MockedFunction, MockInstance } from 'vitest';
99

@@ -1575,7 +1575,7 @@ describe('SSEClientTransport', () => {
15751575
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
15761576
});
15771577

1578-
it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkError', async () => {
1578+
it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkHttpError', async () => {
15791579
postResponses = [401, 401];
15801580
await setupServer();
15811581

@@ -1587,8 +1587,9 @@ describe('SSEClientTransport', () => {
15871587
await transport.start();
15881588

15891589
const error = await transport.send(message).catch(e => e);
1590-
expect(error).toBeInstanceOf(SdkError);
1591-
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
1590+
expect(error).toBeInstanceOf(SdkHttpError);
1591+
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
1592+
expect((error as SdkHttpError).status).toBe(401);
15921593
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
15931594
expect(postCount).toBe(2);
15941595
});

packages/client/test/client/streamableHttp.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
2-
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
2+
import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
33
import type { Mock, Mocked } from 'vitest';
44

55
import type { OAuthClientProvider } from '../../src/client/auth.js';
@@ -240,8 +240,9 @@ describe('StreamableHTTPClientTransport', () => {
240240
transport.onerror = errorSpy;
241241

242242
await expect(transport.send(message)).rejects.toThrow(
243-
new SdkError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
243+
new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
244244
status: 404,
245+
statusText: 'Not Found',
245246
text: 'Session not found'
246247
})
247248
);
@@ -1871,8 +1872,9 @@ describe('StreamableHTTPClientTransport', () => {
18711872
.mockResolvedValueOnce(unauthedResponse);
18721873

18731874
const error = await transport.send(message).catch(e => e);
1874-
expect(error).toBeInstanceOf(SdkError);
1875-
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
1875+
expect(error).toBeInstanceOf(SdkHttpError);
1876+
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
1877+
expect((error as SdkHttpError).status).toBe(401);
18761878
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
18771879
access_token: 'new-access-token',
18781880
token_type: 'Bearer',

packages/client/test/client/tokenProvider.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { IncomingMessage, Server } from 'node:http';
22
import { createServer } from 'node:http';
33

44
import type { JSONRPCMessage, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
5-
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
5+
import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
66
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
77
import type { Mock } from 'vitest';
88

@@ -86,7 +86,7 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => {
8686
expect(retryInit.headers.get('Authorization')).toBe('Bearer new-token');
8787
});
8888

89-
it('should throw SdkError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => {
89+
it('should throw SdkHttpError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => {
9090
const authProvider: AuthProvider = {
9191
token: vi.fn(async () => 'still-bad'),
9292
onUnauthorized: vi.fn(async () => {})
@@ -99,8 +99,9 @@ describe('StreamableHTTPClientTransport with AuthProvider', () => {
9999
.mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' });
100100

101101
const error = await transport.send(message).catch(e => e);
102-
expect(error).toBeInstanceOf(SdkError);
103-
expect((error as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
102+
expect(error).toBeInstanceOf(SdkHttpError);
103+
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
104+
expect((error as SdkHttpError).status).toBe(401);
104105
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
105106
});
106107

packages/core/src/errors/sdkErrors.examples.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @module
88
*/
99

10-
import { SdkError, SdkErrorCode } from './sdkErrors.js';
10+
import { SdkError, SdkErrorCode, SdkHttpError } from './sdkErrors.js';
1111

1212
/**
1313
* Example: Throwing and catching SDK errors.
@@ -25,3 +25,15 @@ function SdkError_basicUsage() {
2525
}
2626
//#endregion SdkError_basicUsage
2727
}
28+
29+
/**
30+
* Example: Checking for HTTP transport errors.
31+
*/
32+
function SdkHttpError_basicUsage(error: unknown) {
33+
//#region SdkHttpError_basicUsage
34+
if (error instanceof SdkHttpError) {
35+
console.log(error.status); // number
36+
console.log(error.statusText); // string | undefined
37+
}
38+
//#endregion SdkHttpError_basicUsage
39+
}

0 commit comments

Comments
 (0)