Skip to content

Commit 0213418

Browse files
feat(compat): McpError/ErrorCode/JSONRPCError/StreamableHTTPError + OAuth subclass aliases
1 parent 9ed62fe commit 0213418

File tree

10 files changed

+266
-22
lines changed

10 files changed

+266
-22
lines changed

.changeset/error-compat-aliases.md

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

packages/client/src/client/streamableHttp.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
isJSONRPCResultResponse,
1010
JSONRPCMessageSchema,
1111
normalizeHeaders,
12-
SdkError,
13-
SdkErrorCode
12+
SdkErrorCode,
13+
StreamableHTTPError
1414
} from '@modelcontextprotocol/core';
1515
import { EventSourceParserStream } from 'eventsource-parser/stream';
1616

@@ -273,9 +273,13 @@ export class StreamableHTTPClientTransport implements Transport {
273273
}
274274
await response.text?.().catch(() => {});
275275
if (isAuthRetry) {
276-
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
277-
status: 401
278-
});
276+
throw new StreamableHTTPError(
277+
SdkErrorCode.ClientHttpAuthentication,
278+
'Server returned 401 after re-authentication',
279+
{
280+
status: 401
281+
}
282+
);
279283
}
280284
throw new UnauthorizedError();
281285
}
@@ -288,10 +292,14 @@ export class StreamableHTTPClientTransport implements Transport {
288292
return;
289293
}
290294

291-
throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
292-
status: response.status,
293-
statusText: response.statusText
294-
});
295+
throw new StreamableHTTPError(
296+
SdkErrorCode.ClientHttpFailedToOpenStream,
297+
`Failed to open SSE stream: ${response.statusText}`,
298+
{
299+
status: response.status,
300+
statusText: response.statusText
301+
}
302+
);
295303
}
296304

297305
this._handleSseStream(response.body, options, true);
@@ -581,9 +589,13 @@ export class StreamableHTTPClientTransport implements Transport {
581589
}
582590
await response.text?.().catch(() => {});
583591
if (isAuthRetry) {
584-
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
585-
status: 401
586-
});
592+
throw new StreamableHTTPError(
593+
SdkErrorCode.ClientHttpAuthentication,
594+
'Server returned 401 after re-authentication',
595+
{
596+
status: 401
597+
}
598+
);
587599
}
588600
throw new UnauthorizedError();
589601
}
@@ -598,7 +610,7 @@ export class StreamableHTTPClientTransport implements Transport {
598610

599611
// Check if we've already tried upscoping with this header to prevent infinite loops.
600612
if (this._lastUpscopingHeader === wwwAuthHeader) {
601-
throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
613+
throw new StreamableHTTPError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
602614
status: 403,
603615
text
604616
});
@@ -629,7 +641,7 @@ export class StreamableHTTPClientTransport implements Transport {
629641
}
630642
}
631643

632-
throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
644+
throw new StreamableHTTPError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
633645
status: response.status,
634646
text
635647
});
@@ -675,7 +687,7 @@ export class StreamableHTTPClientTransport implements Transport {
675687
}
676688
} else {
677689
await response.text?.().catch(() => {});
678-
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
690+
throw new StreamableHTTPError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
679691
contentType
680692
});
681693
}
@@ -725,10 +737,14 @@ export class StreamableHTTPClientTransport implements Transport {
725737
// We specifically handle 405 as a valid response according to the spec,
726738
// meaning the server does not support explicit session termination
727739
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-
});
740+
throw new StreamableHTTPError(
741+
SdkErrorCode.ClientHttpFailedToTerminateSession,
742+
`Failed to terminate session: ${response.statusText}`,
743+
{
744+
status: response.status,
745+
statusText: response.statusText
746+
}
747+
);
732748
}
733749

734750
this._sessionId = undefined;

packages/client/src/validators/cfWorker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
* import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/client/validators/cf-worker';
77
* ```
88
*/
9-
export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core';
109
export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core';
10+
export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core';

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

Lines changed: 2 additions & 2 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, SdkError, SdkErrorCode, StreamableHTTPError } from '@modelcontextprotocol/core';
33
import type { Mock, Mocked } from 'vitest';
44

55
import type { OAuthClientProvider } from '../../src/client/auth.js';
@@ -240,7 +240,7 @@ 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 StreamableHTTPError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
244244
status: 404,
245245
text: 'Session not found'
246246
})

packages/core/src/auth/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ export class OAuthError extends Error {
107107
this.name = 'OAuthError';
108108
}
109109

110+
/**
111+
* @deprecated Use {@linkcode OAuthError.code | .code}.
112+
*/
113+
get errorCode(): string {
114+
return this.code;
115+
}
116+
110117
/**
111118
* Converts the error to a standard OAuth error response object.
112119
*/
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* v1-compat: OAuth error subclasses.
3+
*
4+
* v1 shipped one `Error` subclass per OAuth error code (e.g. `InvalidTokenError`).
5+
* v2 also exposes the consolidated {@link OAuthError} + {@link OAuthErrorCode} enum.
6+
* These thin wrappers preserve `throw new InvalidTokenError(msg)` and `instanceof`
7+
* patterns from v1 and set `.code` to the matching enum value.
8+
*/
9+
10+
import { OAuthError, OAuthErrorCode } from '../auth/errors.js';
11+
12+
type OAuthErrorSubclass = {
13+
new (message: string, errorUri?: string): OAuthError;
14+
/** @deprecated Use the instance `.code` property. */
15+
errorCode: string;
16+
};
17+
18+
function sub(code: OAuthErrorCode, name: string): OAuthErrorSubclass {
19+
return class extends OAuthError {
20+
static errorCode = code as string;
21+
constructor(message: string, errorUri?: string) {
22+
super(code, message, errorUri);
23+
this.name = name;
24+
}
25+
};
26+
}
27+
28+
/* eslint-disable @typescript-eslint/naming-convention */
29+
30+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidRequest`. */
31+
export const InvalidRequestError = sub(OAuthErrorCode.InvalidRequest, 'InvalidRequestError');
32+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClient`. */
33+
export const InvalidClientError = sub(OAuthErrorCode.InvalidClient, 'InvalidClientError');
34+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidGrant`. */
35+
export const InvalidGrantError = sub(OAuthErrorCode.InvalidGrant, 'InvalidGrantError');
36+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnauthorizedClient`. */
37+
export const UnauthorizedClientError = sub(OAuthErrorCode.UnauthorizedClient, 'UnauthorizedClientError');
38+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedGrantType`. */
39+
export const UnsupportedGrantTypeError = sub(OAuthErrorCode.UnsupportedGrantType, 'UnsupportedGrantTypeError');
40+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidScope`. */
41+
export const InvalidScopeError = sub(OAuthErrorCode.InvalidScope, 'InvalidScopeError');
42+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.AccessDenied`. */
43+
export const AccessDeniedError = sub(OAuthErrorCode.AccessDenied, 'AccessDeniedError');
44+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.ServerError`. */
45+
export const ServerError = sub(OAuthErrorCode.ServerError, 'ServerError');
46+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable`. */
47+
export const TemporarilyUnavailableError = sub(OAuthErrorCode.TemporarilyUnavailable, 'TemporarilyUnavailableError');
48+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedResponseType`. */
49+
export const UnsupportedResponseTypeError = sub(OAuthErrorCode.UnsupportedResponseType, 'UnsupportedResponseTypeError');
50+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.UnsupportedTokenType`. */
51+
export const UnsupportedTokenTypeError = sub(OAuthErrorCode.UnsupportedTokenType, 'UnsupportedTokenTypeError');
52+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidToken`. */
53+
export const InvalidTokenError = sub(OAuthErrorCode.InvalidToken, 'InvalidTokenError');
54+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.MethodNotAllowed`. */
55+
export const MethodNotAllowedError = sub(OAuthErrorCode.MethodNotAllowed, 'MethodNotAllowedError');
56+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.TooManyRequests`. */
57+
export const TooManyRequestsError = sub(OAuthErrorCode.TooManyRequests, 'TooManyRequestsError');
58+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidClientMetadata`. */
59+
export const InvalidClientMetadataError = sub(OAuthErrorCode.InvalidClientMetadata, 'InvalidClientMetadataError');
60+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InsufficientScope`. */
61+
export const InsufficientScopeError = sub(OAuthErrorCode.InsufficientScope, 'InsufficientScopeError');
62+
/** @deprecated Use `OAuthError` with `OAuthErrorCode.InvalidTarget`. */
63+
export const InvalidTargetError = sub(OAuthErrorCode.InvalidTarget, 'InvalidTargetError');
64+
65+
/**
66+
* @deprecated Construct {@link OAuthError} directly with a custom code string.
67+
*
68+
* v1 pattern was `class MyErr extends CustomOAuthError { static errorCode = 'my_code' }`;
69+
* this preserves that by reading `static errorCode` from the concrete subclass.
70+
*/
71+
export class CustomOAuthError extends OAuthError {
72+
static errorCode: string;
73+
constructor(message: string, errorUri?: string) {
74+
super((new.target as typeof CustomOAuthError).errorCode, message, errorUri);
75+
}
76+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { SdkErrorCode } from './sdkErrors.js';
2+
import { SdkError } from './sdkErrors.js';
3+
4+
/**
5+
* @deprecated Use {@linkcode SdkError}.
6+
*
7+
* Subclass thrown by the StreamableHTTP client transport for HTTP-level errors.
8+
* `instanceof StreamableHTTPError` and `instanceof SdkError` both match. Note that
9+
* `.code` is now the {@linkcode SdkErrorCode} (a `ClientHttp*` string), not the HTTP
10+
* status number as in v1; the status is available as `.status`.
11+
*/
12+
export class StreamableHTTPError extends SdkError {
13+
public readonly status: number | undefined;
14+
15+
constructor(code: SdkErrorCode, message: string, data?: { status?: number } & Record<string, unknown>) {
16+
super(code, message, data);
17+
this.name = 'StreamableHTTPError';
18+
this.status = data?.status;
19+
}
20+
}

packages/core/src/exports/public/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,64 @@ export { ProtocolErrorCode } from '../../types/enums.js';
100100
// Error classes
101101
export { ProtocolError, UrlElicitationRequiredError } from '../../types/errors.js';
102102

103+
// --- v1-compat aliases ---
104+
import { SdkErrorCode as _SdkErrorCode } from '../../errors/sdkErrors.js';
105+
import { ProtocolErrorCode as _ProtocolErrorCode } from '../../types/enums.js';
106+
/**
107+
* @deprecated Use {@linkcode ProtocolErrorCode} for protocol-level (wire) errors
108+
* or {@linkcode SdkErrorCode} for local SDK errors. Note `ConnectionClosed` /
109+
* `RequestTimeout` moved to `SdkErrorCode` in v2 and are now thrown as `SdkError`,
110+
* not `ProtocolError`.
111+
*/
112+
export const ErrorCode = {
113+
..._ProtocolErrorCode,
114+
/** Now {@linkcode SdkErrorCode.ConnectionClosed}; thrown as `SdkError`, not `McpError`. */
115+
ConnectionClosed: _SdkErrorCode.ConnectionClosed,
116+
/** Now {@linkcode SdkErrorCode.RequestTimeout}; thrown as `SdkError`, not `McpError`. */
117+
RequestTimeout: _SdkErrorCode.RequestTimeout
118+
} as const;
119+
/** @deprecated Use `ProtocolErrorCode` / `SdkErrorCode`. See {@linkcode ErrorCode} const. */
120+
export type ErrorCode = _ProtocolErrorCode | typeof _SdkErrorCode.ConnectionClosed | typeof _SdkErrorCode.RequestTimeout;
121+
export {
122+
/** @deprecated Use {@linkcode ProtocolError} (or `SdkError` for transport-level errors). */
123+
ProtocolError as McpError
124+
} from '../../types/errors.js';
125+
// Note: InvalidRequestError is intentionally omitted here — it collides with the
126+
// JSON-RPC `InvalidRequestError` interface re-exported from types.ts below. v1 users
127+
// imported it from `server/auth/errors.js`, which the sdk meta-package subpath provides.
128+
export {
129+
AccessDeniedError,
130+
CustomOAuthError,
131+
InsufficientScopeError,
132+
InvalidClientError,
133+
InvalidClientMetadataError,
134+
InvalidGrantError,
135+
InvalidScopeError,
136+
InvalidTargetError,
137+
InvalidTokenError,
138+
MethodNotAllowedError,
139+
ServerError,
140+
TemporarilyUnavailableError,
141+
TooManyRequestsError,
142+
UnauthorizedClientError,
143+
UnsupportedGrantTypeError,
144+
UnsupportedResponseTypeError,
145+
UnsupportedTokenTypeError
146+
} from '../../errors/oauthErrorsCompat.js';
147+
export { StreamableHTTPError } from '../../errors/streamableHttpErrorCompat.js';
148+
/** @deprecated Use {@linkcode JSONRPCErrorResponse}. */
149+
export type { JSONRPCErrorResponse as JSONRPCError } from '../../types/spec.types.js';
150+
// --- end v1-compat ---
151+
103152
// Type guards and message parsing
104153
export {
105154
assertCompleteRequestPrompt,
106155
assertCompleteRequestResourceTemplate,
107156
isCallToolResult,
108157
isInitializedNotification,
109158
isInitializeRequest,
159+
/** @deprecated Use {@linkcode isJSONRPCErrorResponse}. */
160+
isJSONRPCErrorResponse as isJSONRPCError,
110161
isJSONRPCErrorResponse,
111162
isJSONRPCNotification,
112163
isJSONRPCRequest,

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './auth/errors.js';
22
export * from './errors/sdkErrors.js';
3+
export * from './errors/streamableHttpErrorCompat.js';
34
export * from './shared/auth.js';
45
export * from './shared/authUtils.js';
56
export * from './shared/metadataUtils.js';
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import {
3+
ErrorCode,
4+
InvalidTokenError,
5+
McpError,
6+
OAuthError,
7+
OAuthErrorCode,
8+
ProtocolError,
9+
ProtocolErrorCode,
10+
SdkError,
11+
SdkErrorCode,
12+
StreamableHTTPError
13+
} from '../../src/exports/public/index.js';
14+
import { CustomOAuthError, ServerError } from '../../src/errors/oauthErrorsCompat.js';
15+
16+
describe('v1-compat error aliases', () => {
17+
it('McpError / ErrorCode alias ProtocolError / ProtocolErrorCode (+ ConnectionClosed/RequestTimeout from SdkErrorCode)', () => {
18+
expect(McpError).toBe(ProtocolError);
19+
expect(ErrorCode.InvalidParams).toBe(ProtocolErrorCode.InvalidParams);
20+
expect(ErrorCode.ConnectionClosed).toBe(SdkErrorCode.ConnectionClosed);
21+
expect(ErrorCode.RequestTimeout).toBe(SdkErrorCode.RequestTimeout);
22+
const e = new McpError(ErrorCode.InvalidParams, 'x');
23+
expect(e).toBeInstanceOf(ProtocolError);
24+
expect(e.code).toBe(ProtocolErrorCode.InvalidParams);
25+
});
26+
27+
it('OAuthError.errorCode getter returns .code (no warning)', () => {
28+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
29+
const e = new OAuthError(OAuthErrorCode.InvalidToken, 'bad');
30+
expect(e.errorCode).toBe(OAuthErrorCode.InvalidToken);
31+
expect(e.errorCode).toBe('invalid_token');
32+
expect(spy).not.toHaveBeenCalled();
33+
spy.mockRestore();
34+
});
35+
36+
it('InvalidTokenError is an OAuthError with .code = InvalidToken (no warning)', () => {
37+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
38+
const e = new InvalidTokenError('expired');
39+
expect(e).toBeInstanceOf(OAuthError);
40+
expect(e.code).toBe(OAuthErrorCode.InvalidToken);
41+
expect(e.message).toBe('expired');
42+
expect(spy).not.toHaveBeenCalled();
43+
spy.mockRestore();
44+
});
45+
46+
it('subclass static errorCode and toResponseObject() match v1 wire format', () => {
47+
expect(ServerError.errorCode).toBe('server_error');
48+
const e = new ServerError('boom');
49+
expect(e.toResponseObject()).toEqual({ error: 'server_error', error_description: 'boom' });
50+
});
51+
52+
it('CustomOAuthError reads static errorCode from concrete subclass', () => {
53+
class MyError extends CustomOAuthError {
54+
static override errorCode = 'my_custom_code';
55+
}
56+
const e = new MyError('nope');
57+
expect(e).toBeInstanceOf(OAuthError);
58+
expect(e.code).toBe('my_custom_code');
59+
});
60+
61+
it('StreamableHTTPError is an SdkError subclass with .status from data', () => {
62+
const e = new StreamableHTTPError(SdkErrorCode.ClientHttpFailedToOpenStream, 'Service Unavailable', { status: 503 });
63+
expect(e).toBeInstanceOf(SdkError);
64+
expect(e.code).toBe(SdkErrorCode.ClientHttpFailedToOpenStream);
65+
expect(e.status).toBe(503);
66+
expect(e.name).toBe('StreamableHTTPError');
67+
});
68+
});

0 commit comments

Comments
 (0)