v2 backwards compat: SdkError status code (#2049)
This commit is contained in:
parent
22595b9685
commit
4f226c1e35
11 changed files with 137 additions and 45 deletions
6
.changeset/add-sdk-http-error.md
Normal file
6
.changeset/add-sdk-http-error.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue