v2: Errors refactor (ProtocolError, SdkError, OAuthError) (#1454)

This commit is contained in:
Konstantin Konstantinov 2026-02-03 18:41:22 +02:00 • committed by GitHub
commit 0f0a4eb7c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1086 additions and 705 deletions

View file

@ -11,21 +11,16 @@ import type {
} from '@modelcontextprotocol/core';
import {
checkResourceAllowed,
InvalidClientError,
InvalidClientMetadataError,
InvalidGrantError,
LATEST_PROTOCOL_VERSION,
OAUTH_ERRORS,
OAuthClientInformationFullSchema,
OAuthError,
OAuthErrorCode,
OAuthErrorResponseSchema,
OAuthMetadataSchema,
OAuthProtectedResourceMetadataSchema,
OAuthTokensSchema,
OpenIdProviderDiscoveryMetadataSchema,
resourceUrlFromServerUrl,
ServerError,
UnauthorizedClientError
resourceUrlFromServerUrl
} from '@modelcontextprotocol/core';
import pkceChallenge from 'pkce-challenge';
@ -328,7 +323,7 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void {
* Parses an OAuth error response from a string or Response object.
*
* If the input is a standard OAuth2.0 error response, it will be parsed according to the spec
* and an instance of the appropriate OAuthError subclass will be returned.
* and an OAuthError will be returned with the appropriate error code.
* If parsing fails, it falls back to a generic ServerError that includes
* the response status (if available) and original content.
*
@ -341,13 +336,11 @@ export async function parseErrorResponse(input: Response | string): Promise<OAut
try {
const result = OAuthErrorResponseSchema.parse(JSON.parse(body));
const { error, error_description, error_uri } = result;
const errorClass = OAUTH_ERRORS[error] || ServerError;
return new errorClass(error_description || '', error_uri);
return OAuthError.fromResponse(result);
} catch (error) {
// Not a valid OAuth error response, but try to inform the user of the raw data anyway
const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`;
return new ServerError(errorMessage);
return new OAuthError(OAuthErrorCode.ServerError, errorMessage);
}
}
@ -371,12 +364,14 @@ export async function auth(
return await authInternal(provider, options);
} catch (error) {
// Handle recoverable error types by invalidating credentials and retrying
if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) {
await provider.invalidateCredentials?.('all');
return await authInternal(provider, options);
} else if (error instanceof InvalidGrantError) {
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
if (error instanceof OAuthError) {
if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) {
await provider.invalidateCredentials?.('all');
return await authInternal(provider, options);
} else if (error.code === OAuthErrorCode.InvalidGrant) {
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
}
}
// Throw otherwise
@ -437,7 +432,8 @@ async function authInternal(
const clientMetadataUrl = provider.clientMetadataUrl;
if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) {
throw new InvalidClientMetadataError(
throw new OAuthError(
OAuthErrorCode.InvalidClientMetadata,
`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`
);
}
@ -502,7 +498,7 @@ async function authInternal(
return 'AUTHORIZED';
} catch (error) {
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
if (!(error instanceof OAuthError) || error instanceof ServerError) {
if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) {
// Could not refresh OAuth tokens
} else {
// Refresh failed for another reason, re-throw

View file

@ -47,7 +47,6 @@ import {
ElicitRequestSchema,
ElicitResultSchema,
EmptyResultSchema,
ErrorCode,
GetPromptResultSchema,
InitializeResultSchema,
LATEST_PROTOCOL_VERSION,
@ -56,11 +55,14 @@ import {
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListToolsResultSchema,
McpError,
mergeCapabilities,
Protocol,
ProtocolError,
ProtocolErrorCode,
ReadResourceResultSchema,
safeParse
safeParse,
SdkError,
SdkErrorCode
} from '@modelcontextprotocol/core';
import { ExperimentalClientTasks } from '../experimental/tasks/client.js';
@ -344,7 +346,7 @@ export class Client<
// Type guard: if success is false, error is guaranteed to exist
const errorMessage =
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`);
}
const { params } = validatedRequest.data;
@ -352,11 +354,11 @@ export class Client<
const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation);
if (params.mode === 'form' && !supportsFormMode) {
throw new McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests');
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests');
}
if (params.mode === 'url' && !supportsUrlMode) {
throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests');
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests');
}
const result = await Promise.resolve(handler(request, extra));
@ -369,7 +371,7 @@ export class Client<
taskValidationResult.error instanceof Error
? taskValidationResult.error.message
: String(taskValidationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
}
return taskValidationResult.data;
}
@ -380,7 +382,7 @@ export class Client<
// Type guard: if success is false, error is guaranteed to exist
const errorMessage =
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`);
}
const validatedResult = validationResult.data;
@ -416,7 +418,7 @@ export class Client<
if (!validatedRequest.success) {
const errorMessage =
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`);
}
const { params } = validatedRequest.data;
@ -431,7 +433,7 @@ export class Client<
taskValidationResult.error instanceof Error
? taskValidationResult.error.message
: String(taskValidationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
}
return taskValidationResult.data;
}
@ -443,7 +445,7 @@ export class Client<
if (!validationResult.success) {
const errorMessage =
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`);
}
return validationResult.data;
@ -542,7 +544,7 @@ export class Client<
switch (method as ClientRequest['method']) {
case 'logging/setLevel': {
if (!this._serverCapabilities?.logging) {
throw new Error(`Server does not support logging (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`);
}
break;
}
@ -550,7 +552,7 @@ export class Client<
case 'prompts/get':
case 'prompts/list': {
if (!this._serverCapabilities?.prompts) {
throw new Error(`Server does not support prompts (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`);
}
break;
}
@ -561,11 +563,14 @@ export class Client<
case 'resources/subscribe':
case 'resources/unsubscribe': {
if (!this._serverCapabilities?.resources) {
throw new Error(`Server does not support resources (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`);
}
if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) {
throw new Error(`Server does not support resource subscriptions (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support resource subscriptions (required for ${method})`
);
}
break;
@ -574,14 +579,14 @@ export class Client<
case 'tools/call':
case 'tools/list': {
if (!this._serverCapabilities?.tools) {
throw new Error(`Server does not support tools (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`);
}
break;
}
case 'completion/complete': {
if (!this._serverCapabilities?.completions) {
throw new Error(`Server does not support completions (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`);
}
break;
}
@ -602,7 +607,10 @@ export class Client<
switch (method as ClientNotification['method']) {
case 'notifications/roots/list_changed': {
if (!this._capabilities.roots?.listChanged) {
throw new Error(`Client does not support roots list changed notifications (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support roots list changed notifications (required for ${method})`
);
}
break;
}
@ -634,21 +642,30 @@ export class Client<
switch (method) {
case 'sampling/createMessage': {
if (!this._capabilities.sampling) {
throw new Error(`Client does not support sampling capability (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support sampling capability (required for ${method})`
);
}
break;
}
case 'elicitation/create': {
if (!this._capabilities.elicitation) {
throw new Error(`Client does not support elicitation capability (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support elicitation capability (required for ${method})`
);
}
break;
}
case 'roots/list': {
if (!this._capabilities.roots) {
throw new Error(`Client does not support roots capability (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support roots capability (required for ${method})`
);
}
break;
}
@ -658,7 +675,10 @@ export class Client<
case 'tasks/result':
case 'tasks/cancel': {
if (!this._capabilities.tasks) {
throw new Error(`Client does not support tasks capability (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support tasks capability (required for ${method})`
);
}
break;
}
@ -753,8 +773,8 @@ export class Client<
) {
// Guard: required-task tools need experimental API
if (this.isToolTaskRequired(params.name)) {
throw new McpError(
ErrorCode.InvalidRequest,
throw new ProtocolError(
ProtocolErrorCode.InvalidRequest,
`Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.`
);
}
@ -766,8 +786,8 @@ export class Client<
if (validator) {
// If tool has outputSchema, it MUST return structuredContent (unless it's an error)
if (!result.structuredContent && !result.isError) {
throw new McpError(
ErrorCode.InvalidRequest,
throw new ProtocolError(
ProtocolErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`
);
}
@ -779,17 +799,17 @@ export class Client<
const validationResult = validator(result.structuredContent);
if (!validationResult.valid) {
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${validationResult.errorMessage}`
);
}
} catch (error) {
if (error instanceof McpError) {
if (error instanceof ProtocolError) {
throw error;
}
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
);
}

View file

@ -16,7 +16,7 @@ export type Middleware = (next: FetchLike) => FetchLike;
* - Add Authorization headers with access tokens
* - Handle 401 responses by attempting re-authentication
* - Retry the original request after successful auth
* - Handle OAuth errors appropriately (InvalidClientError, etc.)
* - Handle OAuth errors appropriately (OAuthErrorCode.InvalidClient, etc.)
*
* The baseUrl parameter is optional and defaults to using the domain from the request URL.
* However, you should explicitly provide baseUrl when:

View file

@ -1,5 +1,5 @@
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders } from '@modelcontextprotocol/core';
import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import type { ErrorEvent, EventSourceInit } from 'eventsource';
import { EventSource } from 'eventsource';
@ -245,7 +245,7 @@ export class SSEClientTransport implements Transport {
async send(message: JSONRPCMessage): Promise<void> {
if (!this._endpoint) {
throw new Error('Not connected');
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
}
try {

View file

@ -4,7 +4,7 @@ import type { Stream } from 'node:stream';
import { PassThrough } from 'node:stream';
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core';
import spawn from 'cross-spawn';
export type StdioServerParameters = {
@ -246,7 +246,7 @@ export class StdioClientTransport implements Transport {
send(message: JSONRPCMessage): Promise<void> {
return new Promise(resolve => {
if (!this._process?.stdin) {
throw new Error('Not connected');
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
}
const json = serializeMessage(message);

View file

@ -7,7 +7,9 @@ import {
isJSONRPCRequest,
isJSONRPCResultResponse,
JSONRPCMessageSchema,
normalizeHeaders
normalizeHeaders,
SdkError,
SdkErrorCode
} from '@modelcontextprotocol/core';
import { EventSourceParserStream } from 'eventsource-parser/stream';
@ -22,15 +24,6 @@ const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOp
maxRetries: 2
};
export class StreamableHTTPError extends Error {
constructor(
public readonly code: number | undefined,
message: string | undefined
) {
super(`Streamable HTTP error: ${message}`);
}
}
/**
* Options for starting or authenticating an SSE connection
*/
@ -248,7 +241,10 @@ export class StreamableHTTPClientTransport implements Transport {
return;
}
throw new StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`);
throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
}
this._handleSseStream(response.body, options, true);
@ -500,7 +496,10 @@ export class StreamableHTTPClientTransport implements Transport {
if (response.status === 401 && this._authProvider) {
// Prevent infinite recursion when server returns 401 after successful auth
if (this._hasCompletedAuthFlow) {
throw new StreamableHTTPError(401, 'Server returned 401 after successful authentication');
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after successful authentication', {
status: 401,
text
});
}
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
@ -531,7 +530,10 @@ 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 StreamableHTTPError(403, 'Server returned 403 after trying upscoping');
throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
status: 403,
text
});
}
if (scope) {
@ -559,7 +561,10 @@ export class StreamableHTTPClientTransport implements Transport {
}
}
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
status: response.status,
text
});
}
// Reset auth loop flag on successful response
@ -604,7 +609,9 @@ export class StreamableHTTPClientTransport implements Transport {
}
} else {
await response.text?.().catch(() => {});
throw new StreamableHTTPError(-1, `Unexpected content type: ${contentType}`);
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
contentType
});
}
} else {
// No requests in message but got 200 OK - still need to release connection
@ -652,7 +659,10 @@ 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 StreamableHTTPError(response.status, `Failed to terminate session: ${response.statusText}`);
throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, {
status: response.status,
statusText: response.statusText
});
}
this._sessionId = undefined;

View file

@ -20,7 +20,7 @@ import type {
Result,
SchemaOutput
} from '@modelcontextprotocol/core';
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/core';
import { CallToolResultSchema, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core';
import type { Client } from '../../client/client.js';
@ -123,8 +123,8 @@ export class ExperimentalClientTasks<
if (!result.structuredContent && !result.isError) {
yield {
type: 'error',
error: new McpError(
ErrorCode.InvalidRequest,
error: new ProtocolError(
ProtocolErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`
)
};
@ -140,22 +140,22 @@ export class ExperimentalClientTasks<
if (!validationResult.valid) {
yield {
type: 'error',
error: new McpError(
ErrorCode.InvalidParams,
error: new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${validationResult.errorMessage}`
)
};
return;
}
} catch (error) {
if (error instanceof McpError) {
if (error instanceof ProtocolError) {
yield { type: 'error', error };
return;
}
yield {
type: 'error',
error: new McpError(
ErrorCode.InvalidParams,
error: new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
)
};

View file

@ -1,7 +1,9 @@
import type { AuthorizationServerMetadata, OAuthTokens } from '@modelcontextprotocol/core';
import { InvalidClientMetadataError, LATEST_PROTOCOL_VERSION, ServerError } from '@modelcontextprotocol/core';
import { expect, type Mock, vi } from 'vitest';
import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core';
import type { Mock } from 'vitest';
import { expect, vi } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth.js';
import {
auth,
buildDiscoveryUrls,
@ -11,7 +13,6 @@ import {
exchangeAuthorization,
extractWWWAuthenticateParams,
isHttpsUrl,
type OAuthClientProvider,
refreshAuthorization,
registerClient,
selectClientAuthMethod,
@ -29,7 +30,7 @@ vi.mock('pkce-challenge', () => ({
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
globalThis.fetch = mockFetch;
describe('OAuth Authorization', () => {
beforeEach(() => {
@ -137,18 +138,13 @@ describe('OAuth Authorization', () => {
mockFetch.mockImplementation((_url, _options) => {
callCount++;
if (callCount === 1) {
// First call with MCP header - fail with TypeError (simulating CORS error)
// We need to use TypeError specifically because that's what the implementation checks for
return Promise.reject(new TypeError('Network error'));
} else {
// Second call without header - succeed
return Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
}
return callCount === 1
? Promise.reject(new TypeError('Network error'))
: Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
});
// Should succeed with the second call
@ -170,13 +166,7 @@ describe('OAuth Authorization', () => {
mockFetch.mockImplementation((_url, _options) => {
callCount++;
if (callCount === 1) {
// First call - fail with TypeError
return Promise.reject(new TypeError('First failure'));
} else {
// Second call - fail with different error
return Promise.reject(new Error('Second failure'));
}
return callCount === 1 ? Promise.reject(new TypeError('First failure')) : Promise.reject(new Error('Second failure'));
});
// Should fail with the second error
@ -608,18 +598,13 @@ describe('OAuth Authorization', () => {
mockFetch.mockImplementation((_url, _options) => {
callCount++;
if (callCount === 1) {
// First call with MCP header - fail with TypeError (simulating CORS error)
// We need to use TypeError specifically because that's what the implementation checks for
return Promise.reject(new TypeError('Network error'));
} else {
// Second call without header - succeed
return Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
}
return callCount === 1
? Promise.reject(new TypeError('Network error'))
: Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
});
// Should succeed with the second call
@ -641,13 +626,7 @@ describe('OAuth Authorization', () => {
mockFetch.mockImplementation((_url, _options) => {
callCount++;
if (callCount === 1) {
// First call - fail with TypeError
return Promise.reject(new TypeError('First failure'));
} else {
// Second call - fail with different error
return Promise.reject(new Error('Second failure'));
}
return callCount === 1 ? Promise.reject(new TypeError('First failure')) : Promise.reject(new Error('Second failure'));
});
// Should fail with the second error
@ -1265,7 +1244,9 @@ describe('OAuth Authorization', () => {
});
it('throws on error response', async () => {
mockFetch.mockResolvedValueOnce(Response.json(new ServerError('Token exchange failed').toResponseObject(), { status: 400 }));
mockFetch.mockResolvedValueOnce(
Response.json(new OAuthError(OAuthErrorCode.ServerError, 'Token exchange failed').toResponseObject(), { status: 400 })
);
await expect(
exchangeAuthorization('https://auth.example.com', {
@ -1458,7 +1439,9 @@ describe('OAuth Authorization', () => {
});
it('throws on error response', async () => {
mockFetch.mockResolvedValueOnce(Response.json(new ServerError('Token refresh failed').toResponseObject(), { status: 400 }));
mockFetch.mockResolvedValueOnce(
Response.json(new OAuthError(OAuthErrorCode.ServerError, 'Token refresh failed').toResponseObject(), { status: 400 })
);
await expect(
refreshAuthorization('https://auth.example.com', {
@ -1478,8 +1461,8 @@ describe('OAuth Authorization', () => {
const validClientInfo = {
client_id: 'client123',
client_secret: 'secret123',
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
client_id_issued_at: 1_612_137_600,
client_secret_expires_at: 1_612_224_000,
...validClientMetadata
};
@ -1544,7 +1527,9 @@ describe('OAuth Authorization', () => {
it('throws on error response', async () => {
mockFetch.mockResolvedValueOnce(
Response.json(new ServerError('Dynamic client registration failed').toResponseObject(), { status: 400 })
Response.json(new OAuthError(OAuthErrorCode.ServerError, 'Dynamic client registration failed').toResponseObject(), {
status: 400
})
);
await expect(
@ -1627,6 +1612,7 @@ describe('OAuth Authorization', () => {
// redirectUrl returns undefined to indicate non-interactive flow
const ccProvider: OAuthClientProvider = {
get redirectUrl() {
// eslint-disable-next-line unicorn/no-useless-undefined
return undefined;
},
get clientMetadata() {
@ -1712,8 +1698,8 @@ describe('OAuth Authorization', () => {
json: async () => ({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
client_id_issued_at: 1_612_137_600,
client_secret_expires_at: 1_612_224_000,
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
})
@ -1784,8 +1770,8 @@ describe('OAuth Authorization', () => {
json: async () => ({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
client_id_issued_at: 1612137600,
client_secret_expires_at: 1612224000,
client_id_issued_at: 1_612_137_600,
client_secret_expires_at: 1_612_224_000,
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
})
@ -3120,7 +3106,7 @@ describe('OAuth Authorization', () => {
auth(providerWithInvalidUri, {
serverUrl: 'https://server.example.com'
})
).rejects.toThrow(InvalidClientMetadataError);
).rejects.toMatchObject({ code: OAuthErrorCode.InvalidClientMetadata });
});
it('throws an error when clientMetadataUrl has root pathname', async () => {
@ -3155,7 +3141,7 @@ describe('OAuth Authorization', () => {
auth(providerWithRootPathname, {
serverUrl: 'https://server.example.com'
})
).rejects.toThrow(InvalidClientMetadataError);
).rejects.toMatchObject({ code: OAuthErrorCode.InvalidClientMetadata });
});
it('throws an error when clientMetadataUrl is not a valid URL', async () => {
@ -3190,7 +3176,7 @@ describe('OAuth Authorization', () => {
auth(providerWithInvalidUrl, {
serverUrl: 'https://server.example.com'
})
).rejects.toThrow(InvalidClientMetadataError);
).rejects.toMatchObject({ code: OAuthErrorCode.InvalidClientMetadata });
});
it('falls back to DCR when client_uri is missing', async () => {

View file

@ -3,7 +3,7 @@ import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { JSONRPCMessage, OAuthTokens } from '@modelcontextprotocol/core';
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core';
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
import type { Mock, Mocked, MockedFunction, MockInstance } from 'vitest';
@ -1001,7 +1001,7 @@ describe('SSEClientTransport', () => {
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
});
it('invalidates all credentials on InvalidClientError during token refresh', async () => {
it('invalidates all credentials on OAuthErrorCode.InvalidClient during token refresh', async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'expired-token',
@ -1009,9 +1009,10 @@ describe('SSEClientTransport', () => {
refresh_token: 'refresh-token'
});
const expectedError = new OAuthError(OAuthErrorCode.InvalidClient, 'Client authentication failed');
let baseUrl = resourceBaseUrl;
// Create server that returns InvalidClientError on token refresh
// Create server that returns OAuthErrorCode.InvalidClient on token refresh
const server = createServer((req, res) => {
lastServerRequest = req;
@ -1031,9 +1032,7 @@ describe('SSEClientTransport', () => {
}
if (req.url === '/token' && req.method === 'POST') {
// Handle token refresh request - return InvalidClientError
const error = new InvalidClientError('Client authentication failed');
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.toResponseObject()));
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(expectedError.toResponseObject()));
return;
}
@ -1050,11 +1049,11 @@ describe('SSEClientTransport', () => {
authProvider: mockAuthProvider
});
await expect(() => transport.start()).rejects.toThrow(InvalidClientError);
await expect(() => transport.start()).rejects.toMatchObject(expectedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
});
it('invalidates all credentials on UnauthorizedClientError during token refresh', async () => {
it('invalidates all credentials on OAuthErrorCode.UnauthorizedClient during token refresh', async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'expired-token',
@ -1062,6 +1061,7 @@ describe('SSEClientTransport', () => {
refresh_token: 'refresh-token'
});
const expectedError = new OAuthError(OAuthErrorCode.UnauthorizedClient, 'Client not authorized');
let baseUrl = resourceBaseUrl;
const server = createServer((req, res) => {
@ -1083,9 +1083,7 @@ describe('SSEClientTransport', () => {
}
if (req.url === '/token' && req.method === 'POST') {
// Handle token refresh request - return UnauthorizedClientError
const error = new UnauthorizedClientError('Client not authorized');
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.toResponseObject()));
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(expectedError.toResponseObject()));
return;
}
@ -1102,17 +1100,19 @@ describe('SSEClientTransport', () => {
authProvider: mockAuthProvider
});
await expect(() => transport.start()).rejects.toThrow(UnauthorizedClientError);
await expect(() => transport.start()).rejects.toMatchObject(expectedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
});
it('invalidates tokens on InvalidGrantError during token refresh', async () => {
it('invalidates tokens on OAuthErrorCode.InvalidGrant during token refresh', async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-token'
});
const expectedError = new OAuthError(OAuthErrorCode.InvalidGrant, 'Invalid refresh token');
let baseUrl = resourceBaseUrl;
const server = createServer((req, res) => {
@ -1134,9 +1134,7 @@ describe('SSEClientTransport', () => {
}
if (req.url === '/token' && req.method === 'POST') {
// Handle token refresh request - return InvalidGrantError
const error = new InvalidGrantError('Invalid refresh token');
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.toResponseObject()));
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(expectedError.toResponseObject()));
return;
}
@ -1153,7 +1151,7 @@ describe('SSEClientTransport', () => {
authProvider: mockAuthProvider
});
await expect(() => transport.start()).rejects.toThrow(InvalidGrantError);
await expect(() => transport.start()).rejects.toMatchObject(expectedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
});
});

View file

@ -1,5 +1,5 @@
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import type { Mock, Mocked } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth.js';
@ -212,7 +212,12 @@ describe('StreamableHTTPClientTransport', () => {
const errorSpy = vi.fn();
transport.onerror = errorSpy;
await expect(transport.send(message)).rejects.toThrow('Streamable HTTP error: Error POSTing to endpoint: Session not found');
await expect(transport.send(message)).rejects.toThrow(
new SdkError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
status: 404,
text: 'Session not found'
})
);
expect(errorSpy).toHaveBeenCalled();
});
@ -1050,7 +1055,7 @@ describe('StreamableHTTPClientTransport', () => {
});
});
it('invalidates all credentials on InvalidClientError during auth', async () => {
it('invalidates all credentials on OAuthErrorCode.InvalidClient during auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
@ -1092,9 +1097,11 @@ describe('StreamableHTTPClientTransport', () => {
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with InvalidClientError
// Token refresh fails with OAuthErrorCode.InvalidClient
.mockResolvedValueOnce(
Response.json(new InvalidClientError('Client authentication failed').toResponseObject(), { status: 400 })
Response.json(new OAuthError(OAuthErrorCode.InvalidClient, 'Client authentication failed').toResponseObject(), {
status: 400
})
)
// Fallback should fail to complete the flow
.mockResolvedValue({
@ -1107,7 +1114,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send(message).catch(() => {});
});
it('invalidates all credentials on UnauthorizedClientError during auth', async () => {
it('invalidates all credentials on OAuthErrorCode.UnauthorizedClient during auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
@ -1149,8 +1156,12 @@ describe('StreamableHTTPClientTransport', () => {
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with UnauthorizedClientError
.mockResolvedValueOnce(Response.json(new UnauthorizedClientError('Client not authorized').toResponseObject(), { status: 400 }))
// Token refresh fails with OAuthErrorCode.UnauthorizedClient
.mockResolvedValueOnce(
Response.json(new OAuthError(OAuthErrorCode.UnauthorizedClient, 'Client not authorized').toResponseObject(), {
status: 400
})
)
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
@ -1165,7 +1176,7 @@ describe('StreamableHTTPClientTransport', () => {
await transport.send(message).catch(() => {});
});
it('invalidates tokens on InvalidGrantError during auth', async () => {
it('invalidates tokens on OAuthErrorCode.InvalidGrant during auth', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
@ -1207,8 +1218,10 @@ describe('StreamableHTTPClientTransport', () => {
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with InvalidGrantError
.mockResolvedValueOnce(Response.json(new InvalidGrantError('Invalid refresh token').toResponseObject(), { status: 400 }))
// Token refresh fails with OAuthErrorCode.InvalidGrant
.mockResolvedValueOnce(
Response.json(new OAuthError(OAuthErrorCode.InvalidGrant, 'Invalid refresh token').toResponseObject(), { status: 400 })
)
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
@ -1218,7 +1231,7 @@ describe('StreamableHTTPClientTransport', () => {
}
});
// Behavior for InvalidGrantError during auth is covered in dedicated OAuth
// Behavior for OAuthErrorCode.InvalidGrant during auth is covered in dedicated OAuth
// unit tests and SSE transport tests. Here we just assert that the call
// path completes without unhandled rejections.
await transport.send(message).catch(() => {});
@ -1255,9 +1268,11 @@ describe('StreamableHTTPClientTransport', () => {
code_challenge_methods_supported: ['S256']
})
})
// Token refresh fails with InvalidClientError
// Token refresh fails with OAuthErrorCode.InvalidClient
.mockResolvedValueOnce(
Response.json(new InvalidClientError('Client authentication failed').toResponseObject(), { status: 400 })
Response.json(new OAuthError(OAuthErrorCode.InvalidClient, 'Client authentication failed').toResponseObject(), {
status: 400
})
)
// Fallback should fail to complete the flow
.mockResolvedValue({

View file

@ -1,25 +1,117 @@
import type { OAuthErrorResponse } from '../shared/auth.js';
/**
* Base class for all OAuth errors
* OAuth error codes as defined by RFC 6749 and extensions.
*/
export enum OAuthErrorCode {
/**
* The request is missing a required parameter, includes an invalid parameter value,
* includes a parameter more than once, or is otherwise malformed.
*/
InvalidRequest = 'invalid_request',
/**
* Client authentication failed (e.g., unknown client, no client authentication included,
* or unsupported authentication method).
*/
InvalidClient = 'invalid_client',
/**
* The provided authorization grant or refresh token is invalid, expired, revoked,
* does not match the redirection URI used in the authorization request, or was issued to another client.
*/
InvalidGrant = 'invalid_grant',
/**
* The authenticated client is not authorized to use this authorization grant type.
*/
UnauthorizedClient = 'unauthorized_client',
/**
* The authorization grant type is not supported by the authorization server.
*/
UnsupportedGrantType = 'unsupported_grant_type',
/**
* The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner.
*/
InvalidScope = 'invalid_scope',
/**
* The resource owner or authorization server denied the request.
*/
AccessDenied = 'access_denied',
/**
* The authorization server encountered an unexpected condition that prevented it from fulfilling the request.
*/
ServerError = 'server_error',
/**
* The authorization server is currently unable to handle the request due to temporary overloading or maintenance.
*/
TemporarilyUnavailable = 'temporarily_unavailable',
/**
* The authorization server does not support obtaining an authorization code using this method.
*/
UnsupportedResponseType = 'unsupported_response_type',
/**
* The authorization server does not support the requested token type.
*/
UnsupportedTokenType = 'unsupported_token_type',
/**
* The access token provided is expired, revoked, malformed, or invalid for other reasons.
*/
InvalidToken = 'invalid_token',
/**
* The HTTP method used is not allowed for this endpoint. (Custom, non-standard error)
*/
MethodNotAllowed = 'method_not_allowed',
/**
* Rate limit exceeded. (Custom, non-standard error based on RFC 6585)
*/
TooManyRequests = 'too_many_requests',
/**
* The client metadata is invalid. (Custom error for dynamic client registration - RFC 7591)
*/
InvalidClientMetadata = 'invalid_client_metadata',
/**
* The request requires higher privileges than provided by the access token.
*/
InsufficientScope = 'insufficient_scope',
/**
* The requested resource is invalid, missing, unknown, or malformed. (Custom error for resource indicators - RFC 8707)
*/
InvalidTarget = 'invalid_target'
}
/**
* OAuth error class for all OAuth-related errors.
*/
export class OAuthError extends Error {
static errorCode: string;
constructor(
public readonly code: OAuthErrorCode | string,
message: string,
public readonly errorUri?: string
) {
super(message);
this.name = this.constructor.name;
this.name = 'OAuthError';
}
/**
* Converts the error to a standard OAuth error response object
* Converts the error to a standard OAuth error response object.
*/
toResponseObject(): OAuthErrorResponse {
const response: OAuthErrorResponse = {
error: this.errorCode,
error: this.code,
error_description: this.message
};
@ -30,183 +122,10 @@ export class OAuthError extends Error {
return response;
}
get errorCode(): string {
return (this.constructor as typeof OAuthError).errorCode;
/**
* Creates an OAuthError from an OAuth error response.
*/
static fromResponse(response: OAuthErrorResponse): OAuthError {
return new OAuthError(response.error as OAuthErrorCode, response.error_description ?? response.error, response.error_uri);
}
}
/**
* Invalid request error - The request is missing a required parameter,
* includes an invalid parameter value, includes a parameter more than once,
* or is otherwise malformed.
*/
export class InvalidRequestError extends OAuthError {
static override errorCode = 'invalid_request';
}
/**
* Invalid client error - Client authentication failed (e.g., unknown client, no client
* authentication included, or unsupported authentication method).
*/
export class InvalidClientError extends OAuthError {
static override errorCode = 'invalid_client';
}
/**
* Invalid grant error - The provided authorization grant or refresh token is
* invalid, expired, revoked, does not match the redirection URI used in the
* authorization request, or was issued to another client.
*/
export class InvalidGrantError extends OAuthError {
static override errorCode = 'invalid_grant';
}
/**
* Unauthorized client error - The authenticated client is not authorized to use
* this authorization grant type.
*/
export class UnauthorizedClientError extends OAuthError {
static override errorCode = 'unauthorized_client';
}
/**
* Unsupported grant type error - The authorization grant type is not supported
* by the authorization server.
*/
export class UnsupportedGrantTypeError extends OAuthError {
static override errorCode = 'unsupported_grant_type';
}
/**
* Invalid scope error - The requested scope is invalid, unknown, malformed, or
* exceeds the scope granted by the resource owner.
*/
export class InvalidScopeError extends OAuthError {
static override errorCode = 'invalid_scope';
}
/**
* Access denied error - The resource owner or authorization server denied the request.
*/
export class AccessDeniedError extends OAuthError {
static override errorCode = 'access_denied';
}
/**
* Server error - The authorization server encountered an unexpected condition
* that prevented it from fulfilling the request.
*/
export class ServerError extends OAuthError {
static override errorCode = 'server_error';
}
/**
* Temporarily unavailable error - The authorization server is currently unable to
* handle the request due to a temporary overloading or maintenance of the server.
*/
export class TemporarilyUnavailableError extends OAuthError {
static override errorCode = 'temporarily_unavailable';
}
/**
* Unsupported response type error - The authorization server does not support
* obtaining an authorization code using this method.
*/
export class UnsupportedResponseTypeError extends OAuthError {
static override errorCode = 'unsupported_response_type';
}
/**
* Unsupported token type error - The authorization server does not support
* the requested token type.
*/
export class UnsupportedTokenTypeError extends OAuthError {
static override errorCode = 'unsupported_token_type';
}
/**
* Invalid token error - The access token provided is expired, revoked, malformed,
* or invalid for other reasons.
*/
export class InvalidTokenError extends OAuthError {
static override errorCode = 'invalid_token';
}
/**
* Method not allowed error - The HTTP method used is not allowed for this endpoint.
* (Custom, non-standard error)
*/
export class MethodNotAllowedError extends OAuthError {
static override errorCode = 'method_not_allowed';
}
/**
* Too many requests error - Rate limit exceeded.
* (Custom, non-standard error based on RFC 6585)
*/
export class TooManyRequestsError extends OAuthError {
static override errorCode = 'too_many_requests';
}
/**
* Invalid client metadata error - The client metadata is invalid.
* (Custom error for dynamic client registration - RFC 7591)
*/
export class InvalidClientMetadataError extends OAuthError {
static override errorCode = 'invalid_client_metadata';
}
/**
* Insufficient scope error - The request requires higher privileges than provided by the access token.
*/
export class InsufficientScopeError extends OAuthError {
static override errorCode = 'insufficient_scope';
}
/**
* Invalid target error - The requested resource is invalid, missing, unknown, or malformed.
* (Custom error for resource indicators - RFC 8707)
*/
export class InvalidTargetError extends OAuthError {
static override errorCode = 'invalid_target';
}
/**
* A utility class for defining one-off error codes
*/
export class CustomOAuthError extends OAuthError {
constructor(
private readonly customErrorCode: string,
message: string,
errorUri?: string
) {
super(message, errorUri);
}
override get errorCode(): string {
return this.customErrorCode;
}
}
/**
* A full list of all OAuthErrors, enabling parsing from error responses
*/
export const OAUTH_ERRORS = {
[InvalidRequestError.errorCode]: InvalidRequestError,
[InvalidClientError.errorCode]: InvalidClientError,
[InvalidGrantError.errorCode]: InvalidGrantError,
[UnauthorizedClientError.errorCode]: UnauthorizedClientError,
[UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError,
[InvalidScopeError.errorCode]: InvalidScopeError,
[AccessDeniedError.errorCode]: AccessDeniedError,
[ServerError.errorCode]: ServerError,
[TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError,
[UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError,
[UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError,
[InvalidTokenError.errorCode]: InvalidTokenError,
[MethodNotAllowedError.errorCode]: MethodNotAllowedError,
[TooManyRequestsError.errorCode]: TooManyRequestsError,
[InvalidClientMetadataError.errorCode]: InvalidClientMetadataError,
[InsufficientScopeError.errorCode]: InsufficientScopeError,
[InvalidTargetError.errorCode]: InvalidTargetError
} as const;

View file

@ -0,0 +1,64 @@
/**
* Error codes for SDK errors (local errors that never cross the wire).
* Unlike ProtocolErrorCode which uses numeric JSON-RPC codes, SdkErrorCode uses
* descriptive string values for better developer experience.
*
* These errors are thrown locally by the SDK and are never serialized as
* JSON-RPC error responses.
*/
export enum SdkErrorCode {
// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** Protocol is not initialized */
NotInitialized = 'NOT_INITIALIZED',
// Capability errors
/** Required capability is not supported by the remote side */
CapabilityNotSupported = 'CAPABILITY_NOT_SUPPORTED',
// Transport errors
/** Request timed out waiting for response */
RequestTimeout = 'REQUEST_TIMEOUT',
/** Connection was closed */
ConnectionClosed = 'CONNECTION_CLOSED',
/** Failed to send message */
SendFailed = 'SEND_FAILED',
// Transport errors
ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED',
ClientHttpAuthentication = 'CLIENT_HTTP_AUTHENTICATION',
ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN',
ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT',
ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM',
ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION'
}
/**
* SDK errors are local errors that never cross the wire.
* They are distinct from ProtocolError which represents JSON-RPC protocol errors
* that are serialized and sent as error responses.
*
* @example
* ```typescript
* // Throwing an SDK error
* throw new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected');
*
* // Checking error type by code
* if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
* // Handle timeout
* }
* ```
*/
export class SdkError extends Error {
constructor(
public readonly code: SdkErrorCode,
message: string,
public readonly data?: unknown
) {
super(message);
this.name = 'SdkError';
}
}

View file

@ -1,4 +1,5 @@
export * from './auth/errors.js';
export * from './errors/sdkErrors.js';
export * from './shared/auth.js';
export * from './shared/authUtils.js';
export * from './shared/metadataUtils.js';

View file

@ -1,3 +1,4 @@
import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js';
import { isTerminal } from '../experimental/tasks/interfaces.js';
import type {
@ -34,7 +35,6 @@ import type {
import {
CancelTaskResultSchema,
CreateTaskResultSchema,
ErrorCode,
getNotificationSchema,
getRequestSchema,
GetTaskResultSchema,
@ -44,7 +44,8 @@ import {
isJSONRPCResultResponse,
isTaskAugmentedRequestParams,
ListTasksResultSchema,
McpError,
ProtocolError,
ProtocolErrorCode,
RELATED_TASK_META_KEY,
SUPPORTED_PROTOCOL_VERSIONS,
TaskStatusNotificationSchema
@ -134,7 +135,7 @@ export type RequestOptions = {
signal?: AbortSignal;
/**
* A timeout (in milliseconds) for this request. If exceeded, an McpError with code `RequestTimeout` will be raised from request().
* A timeout (in milliseconds) for this request. If exceeded, an SdkError with code `SdkErrorCode.RequestTimeout` will be raised from request().
*
* If not specified, `DEFAULT_REQUEST_TIMEOUT_MSEC` will be used as the timeout.
*/
@ -149,7 +150,7 @@ export type RequestOptions = {
/**
* Maximum total time (in milliseconds) to wait for a response.
* If exceeded, an McpError with code `RequestTimeout` will be raised, regardless of progress notifications.
* If exceeded, an SdkError with code `SdkErrorCode.RequestTimeout` will be raised, regardless of progress notifications.
* If not specified, there is no maximum total timeout.
*/
maxTotalTimeout?: number;
@ -401,7 +402,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
this.setRequestHandler('tasks/get', async (request, extra) => {
const task = await this._taskStore!.getTask(request.params.taskId, extra.sessionId);
if (!task) {
throw new McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
}
// Per spec: tasks/get responses SHALL NOT include related-task metadata
@ -435,9 +436,9 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
if (queuedMessage.type === 'response') {
resolver(message as JSONRPCResultResponse);
} else {
// Convert JSONRPCError to McpError
// Convert JSONRPCError to ProtocolError
const errorMessage = message as JSONRPCErrorResponse;
const error = new McpError(
const error = new ProtocolError(
errorMessage.error.code,
errorMessage.error.message,
errorMessage.error.data
@ -463,7 +464,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
// Now check task status
const task = await this._taskStore!.getTask(taskId, extra.sessionId);
if (!task) {
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`);
}
// Block if task is not terminal (we've already delivered all queued messages above)
@ -507,8 +508,8 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
_meta: {}
} as unknown as SendResultT;
} catch (error) {
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Failed to list tasks: ${error instanceof Error ? error.message : String(error)}`
);
}
@ -520,12 +521,12 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
const task = await this._taskStore!.getTask(request.params.taskId, extra.sessionId);
if (!task) {
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${request.params.taskId}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${request.params.taskId}`);
}
// Reject cancellation of terminal tasks
if (isTerminal(task.status)) {
throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
}
await this._taskStore!.updateTaskStatus(
@ -540,7 +541,10 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
const cancelledTask = await this._taskStore!.getTask(request.params.taskId, extra.sessionId);
if (!cancelledTask) {
// Task was deleted during cancellation (e.g., cleanup happened)
throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Task not found after cancellation: ${request.params.taskId}`
);
}
return {
@ -548,12 +552,12 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
...cancelledTask
} as unknown as SendResultT;
} catch (error) {
// Re-throw McpError as-is
if (error instanceof McpError) {
// Re-throw ProtocolError as-is
if (error instanceof ProtocolError) {
throw error;
}
throw new McpError(
ErrorCode.InvalidRequest,
throw new ProtocolError(
ProtocolErrorCode.InvalidRequest,
`Failed to cancel task: ${error instanceof Error ? error.message : String(error)}`
);
}
@ -594,7 +598,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
const totalElapsed = Date.now() - info.startTime;
if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) {
this._timeoutInfo.delete(messageId);
throw McpError.fromError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
maxTotalTimeout: info.maxTotalTimeout,
totalElapsed
});
@ -659,7 +663,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
this._taskProgressTokens.clear();
this._pendingDebouncedNotifications.clear();
const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed');
const error = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed');
this._transport = undefined;
this.onclose?.();
@ -701,7 +705,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
jsonrpc: '2.0',
id: request.id,
error: {
code: ErrorCode.MethodNotFound,
code: ProtocolErrorCode.MethodNotFound,
message: 'Method not found'
}
};
@ -815,7 +819,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
jsonrpc: '2.0',
id: request.id,
error: {
code: Number.isSafeInteger(error['code']) ? error['code'] : ErrorCode.InternalError,
code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError,
message: error.message ?? 'Internal error',
...(error['data'] !== undefined && { data: error['data'] })
}
@ -880,7 +884,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
if (isJSONRPCResultResponse(response)) {
resolver(response);
} else {
const error = new McpError(response.error.code, response.error.message, response.error.data);
const error = new ProtocolError(response.error.code, response.error.message, response.error.data);
resolver(error);
}
return;
@ -915,7 +919,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
if (isJSONRPCResultResponse(response)) {
handler(response);
} else {
const error = McpError.fromError(response.error.code, response.error.message, response.error.data);
const error = ProtocolError.fromError(response.error.code, response.error.message, response.error.data);
handler(error);
}
}
@ -1008,7 +1012,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
} catch (error) {
yield {
type: 'error',
error: error instanceof McpError ? error : new McpError(ErrorCode.InternalError, String(error))
error: error instanceof Error ? error : new Error(String(error))
};
}
return;
@ -1026,7 +1030,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
taskId = createResult.task.taskId;
yield { type: 'taskCreated', task: createResult.task };
} else {
throw new McpError(ErrorCode.InternalError, 'Task creation did not return a task');
throw new ProtocolError(ProtocolErrorCode.InternalError, 'Task creation did not return a task');
}
// Poll for task completion
@ -1048,7 +1052,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
case 'failed': {
yield {
type: 'error',
error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`)
error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} failed`)
};
break;
@ -1056,7 +1060,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
case 'cancelled': {
yield {
type: 'error',
error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`)
error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} was cancelled`)
};
break;
@ -1084,7 +1088,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
} catch (error) {
yield {
type: 'error',
error: error instanceof McpError ? error : new McpError(ErrorCode.InternalError, String(error))
error: error instanceof Error ? error : new Error(String(error))
};
}
}
@ -1180,8 +1184,8 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
)
.catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`)));
// Wrap the reason in an McpError if it isn't already
const error = reason instanceof McpError ? reason : new McpError(ErrorCode.RequestTimeout, String(reason));
// Wrap the reason in an SdkError if it isn't already
const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason));
reject(error);
};
@ -1212,7 +1216,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
});
const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, 'Request timed out', { timeout }));
const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout }));
this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false);
@ -1301,7 +1305,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
*/
async notification(notification: SendNotificationT, options?: NotificationOptions): Promise<void> {
if (!this._transport) {
throw new Error('Not connected');
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
}
this.assertNotificationCapability(notification.method);
@ -1518,7 +1522,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
const requestId = message.message.id as RequestId;
const resolver = this._requestResolvers.get(requestId);
if (resolver) {
resolver(new McpError(ErrorCode.InternalError, 'Task cancelled or completed'));
resolver(new ProtocolError(ProtocolErrorCode.InternalError, 'Task cancelled or completed'));
this._requestResolvers.delete(requestId);
} else {
// Log error when resolver is missing during cleanup for better observability
@ -1550,7 +1554,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new McpError(ErrorCode.InvalidRequest, 'Request cancelled'));
reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled'));
return;
}
@ -1562,7 +1566,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
'abort',
() => {
clearTimeout(timeoutId);
reject(new McpError(ErrorCode.InvalidRequest, 'Request cancelled'));
reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled'));
},
{ once: true }
);
@ -1594,7 +1598,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
getTask: async taskId => {
const task = await taskStore.getTask(taskId, sessionId);
if (!task) {
throw new McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
}
return task;
@ -1624,13 +1628,13 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
// Check if task exists
const task = await taskStore.getTask(taskId, sessionId);
if (!task) {
throw new McpError(ErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`);
}
// Don't allow transitions from terminal states
if (isTerminal(task.status)) {
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.`
);
}

View file

@ -1,4 +1,4 @@
import type { McpError, Result, Task } from '../types/types.js';
import type { Result, Task } from '../types/types.js';
/**
* Base message type
@ -36,7 +36,7 @@ export interface ResultMessage<T extends Result> extends BaseResponseMessage {
*/
export interface ErrorMessage extends BaseResponseMessage {
type: 'error';
error: McpError;
error: Error;
}
/**

View file

@ -216,13 +216,10 @@ export const isJSONRPCResultResponse = (value: unknown): value is JSONRPCResultR
JSONRPCResultResponseSchema.safeParse(value).success;
/**
* Error codes defined by the JSON-RPC specification.
* Error codes for protocol errors that cross the wire as JSON-RPC error responses.
* These follow the JSON-RPC specification and MCP-specific extensions.
*/
export enum ErrorCode {
// SDK error codes
ConnectionClosed = -32_000,
RequestTimeout = -32_001,
export enum ProtocolErrorCode {
// Standard JSON-RPC error codes
ParseError = -32_700,
InvalidRequest = -32_600,
@ -2302,30 +2299,34 @@ export const ServerResultSchema = z.union([
CreateTaskResultSchema
]);
export class McpError extends Error {
/**
* Protocol errors are JSON-RPC errors that cross the wire as error responses.
* They use numeric error codes from the ProtocolErrorCode enum.
*/
export class ProtocolError extends Error {
constructor(
public readonly code: number,
message: string,
public readonly data?: unknown
) {
super(`MCP error ${code}: ${message}`);
this.name = 'McpError';
this.name = 'ProtocolError';
}
/**
* Factory method to create the appropriate error type based on the error code and data
*/
static fromError(code: number, message: string, data?: unknown): McpError {
static fromError(code: number, message: string, data?: unknown): ProtocolError {
// Check for specific error types
if (code === ErrorCode.UrlElicitationRequired && data) {
if (code === ProtocolErrorCode.UrlElicitationRequired && data) {
const errorData = data as { elicitations?: unknown[] };
if (errorData.elicitations) {
return new UrlElicitationRequiredError(errorData.elicitations as ElicitRequestURLParams[], message);
}
}
// Default to generic McpError
return new McpError(code, message, data);
// Default to generic ProtocolError
return new ProtocolError(code, message, data);
}
}
@ -2333,9 +2334,9 @@ export class McpError extends Error {
* Specialized error type when a tool requires a URL mode elicitation.
* This makes it nicer for the client to handle since there is specific data to work with instead of just a code to check against.
*/
export class UrlElicitationRequiredError extends McpError {
export class UrlElicitationRequiredError extends ProtocolError {
constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) {
super(ErrorCode.UrlElicitationRequired, message, {
super(ProtocolErrorCode.UrlElicitationRequired, message, {
elicitations: elicitations
});
}

View file

@ -1,3 +1,4 @@
import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
import type { Transport } from '../shared/transport.js';
import type { AuthInfo, JSONRPCMessage, RequestId } from '../types/types.js';
@ -50,7 +51,7 @@ export class InMemoryTransport implements Transport {
*/
async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId; authInfo?: AuthInfo }): Promise<void> {
if (!this._otherTransport) {
throw new Error('Not connected');
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
}
if (this._otherTransport.onmessage) {

View file

@ -29,7 +29,8 @@ import type {
Task,
TaskCreationParams
} from '../../src/types/types.js';
import { ErrorCode, McpError, RELATED_TASK_META_KEY } from '../../src/types/types.js';
import { ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/types.js';
import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js';
// Type helper for accessing private/protected Protocol properties in tests
interface TestProtocol {
@ -175,9 +176,9 @@ describe('protocol tests', () => {
timeout: 0
});
} catch (error) {
expect(error).toBeInstanceOf(McpError);
if (error instanceof McpError) {
expect(error.code).toBe(ErrorCode.RequestTimeout);
expect(error).toBeInstanceOf(SdkError);
if (error instanceof SdkError) {
expect(error.code).toBe(SdkErrorCode.RequestTimeout);
}
}
});
@ -2230,7 +2231,7 @@ describe('Request Cancellation vs Task Cancellation', () => {
jsonrpc: '2.0',
id: 999,
error: expect.objectContaining({
code: ErrorCode.InvalidParams,
code: ProtocolErrorCode.InvalidParams,
message: expect.stringContaining('Cannot cancel task in terminal status')
})
})
@ -2262,7 +2263,7 @@ describe('Request Cancellation vs Task Cancellation', () => {
jsonrpc: '2.0',
id: 999,
error: expect.objectContaining({
code: ErrorCode.InvalidParams,
code: ProtocolErrorCode.InvalidParams,
message: expect.stringContaining('Task not found')
})
})
@ -3717,7 +3718,7 @@ describe('Message Interception', () => {
// Set up a request handler that throws an error
protocol.setRequestHandler('ping', async () => {
throw new McpError(ErrorCode.InternalError, 'Test error message');
throw new ProtocolError(ProtocolErrorCode.InternalError, 'Test error message');
});
// Simulate an incoming request with relatedTask metadata
@ -3746,7 +3747,7 @@ describe('Message Interception', () => {
expect(queuedMessage!.type).toBe('error');
if (queuedMessage!.type === 'error') {
expect(queuedMessage!.message.id).toBe(requestId);
expect(queuedMessage!.message.error.code).toBe(ErrorCode.InternalError);
expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.InternalError);
expect(queuedMessage!.message.error.message).toContain('Test error message');
}
});
@ -3780,7 +3781,7 @@ describe('Message Interception', () => {
expect(queuedMessage!.type).toBe('error');
if (queuedMessage!.type === 'error') {
expect(queuedMessage!.message.id).toBe(requestId);
expect(queuedMessage!.message.error.code).toBe(ErrorCode.MethodNotFound);
expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.MethodNotFound);
}
});
@ -4260,7 +4261,7 @@ describe('Queue lifecycle management', () => {
// Verify the request promise is rejected
const result = await requestPromise;
expect(result).toBeInstanceOf(McpError);
expect(result).toBeInstanceOf(ProtocolError);
expect(result.message).toContain('Task cancelled or completed');
// Verify queue is cleared (no messages available)
@ -4322,7 +4323,7 @@ describe('Queue lifecycle management', () => {
// Verify the request promise is rejected
const result = await requestPromise;
expect(result).toBeInstanceOf(McpError);
expect(result).toBeInstanceOf(ProtocolError);
expect(result.message).toContain('Task cancelled or completed');
// Verify queue is cleared (no messages available)
@ -4370,11 +4371,11 @@ describe('Queue lifecycle management', () => {
const result2 = await request2Promise;
const result3 = await request3Promise;
expect(result1).toBeInstanceOf(McpError);
expect(result1).toBeInstanceOf(ProtocolError);
expect(result1.message).toContain('Task cancelled or completed');
expect(result2).toBeInstanceOf(McpError);
expect(result2).toBeInstanceOf(ProtocolError);
expect(result2.message).toContain('Task cancelled or completed');
expect(result3).toBeInstanceOf(McpError);
expect(result3).toBeInstanceOf(ProtocolError);
expect(result3.message).toContain('Task cancelled or completed');
// Verify queue is cleared (no messages available)
@ -4410,7 +4411,7 @@ describe('Queue lifecycle management', () => {
// Verify request promise is rejected
const result = await requestPromise;
expect(result).toBeInstanceOf(McpError);
expect(result).toBeInstanceOf(ProtocolError);
expect(result.message).toContain('Task cancelled or completed');
// Verify resolver mapping is cleaned up
@ -4499,7 +4500,7 @@ describe('requestStream() method', () => {
jsonrpc: '2.0',
id: 0,
error: {
code: ErrorCode.InternalError,
code: ProtocolErrorCode.InternalError,
message: 'Test error'
}
});
@ -4577,7 +4578,7 @@ describe('requestStream() method', () => {
jsonrpc: '2.0',
id: 0,
error: {
code: ErrorCode.InternalError,
code: ProtocolErrorCode.InternalError,
message: 'Server error'
}
});
@ -4627,7 +4628,8 @@ describe('requestStream() method', () => {
const lastMessage = messages[messages.length - 1];
assertErrorResponse(lastMessage!);
expect(lastMessage.error).toBeDefined();
expect(lastMessage.error.code).toBe(ErrorCode.RequestTimeout);
expect(lastMessage.error).toBeInstanceOf(SdkError);
expect((lastMessage.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
} finally {
vi.useRealTimers();
}
@ -4690,7 +4692,7 @@ describe('requestStream() method', () => {
jsonrpc: '2.0',
id: 0,
error: {
code: ErrorCode.InternalError,
code: ProtocolErrorCode.InternalError,
message: 'Test error'
}
});
@ -4830,7 +4832,7 @@ describe('requestStream() method', () => {
jsonrpc: '2.0',
id: 0,
error: {
code: ErrorCode.InternalError,
code: ProtocolErrorCode.InternalError,
message: 'Test error'
}
});
@ -5027,11 +5029,11 @@ describe('Error handling for missing resolvers', () => {
await testProtocol._clearTaskQueue(task.taskId);
// Verify resolver was called with cancellation error
expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError));
expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError));
// Verify the error has the correct properties
const calledError = resolverMock.mock.calls[0]![0];
expect(calledError.code).toBe(ErrorCode.InternalError);
expect(calledError.code).toBe(ProtocolErrorCode.InternalError);
expect(calledError.message).toContain('Task cancelled or completed');
// Verify resolver was removed
@ -5087,11 +5089,11 @@ describe('Error handling for missing resolvers', () => {
await testProtocol._clearTaskQueue(task.taskId);
// Verify resolver was called for first request
expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError));
expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError));
// Verify the error has the correct properties
const calledError = resolverMock.mock.calls[0]![0];
expect(calledError.code).toBe(ErrorCode.InternalError);
expect(calledError.code).toBe(ProtocolErrorCode.InternalError);
expect(calledError.message).toContain('Task cancelled or completed');
// Verify error was logged for second request
@ -5217,7 +5219,7 @@ describe('Error handling for missing resolvers', () => {
jsonrpc: '2.0',
id: requestId,
error: {
code: ErrorCode.InvalidRequest,
code: ProtocolErrorCode.InvalidRequest,
message: 'Invalid request parameters'
}
},
@ -5237,15 +5239,15 @@ describe('Error handling for missing resolvers', () => {
if (resolver) {
testProtocol._requestResolvers.delete(reqId);
const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
resolver(error);
}
}
// Verify resolver was called with McpError
expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError));
// Verify resolver was called with ProtocolError
expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError));
const calledError = resolverMock.mock.calls[0]![0];
expect(calledError.code).toBe(ErrorCode.InvalidRequest);
expect(calledError.code).toBe(ProtocolErrorCode.InvalidRequest);
expect(calledError.message).toContain('Invalid request parameters');
// Verify resolver was removed from map
@ -5264,7 +5266,7 @@ describe('Error handling for missing resolvers', () => {
jsonrpc: '2.0',
id: 999,
error: {
code: ErrorCode.InternalError,
code: ProtocolErrorCode.InternalError,
message: 'Something went wrong'
}
},
@ -5314,7 +5316,7 @@ describe('Error handling for missing resolvers', () => {
jsonrpc: '2.0',
id: requestId,
error: {
code: ErrorCode.InvalidParams,
code: ProtocolErrorCode.InvalidParams,
message: 'Validation failed',
data: { field: 'userName', reason: 'required' }
}
@ -5332,15 +5334,15 @@ describe('Error handling for missing resolvers', () => {
if (resolver) {
testProtocol._requestResolvers.delete(reqId);
const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
resolver(error);
}
}
// Verify resolver was called with McpError including data
expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError));
// Verify resolver was called with ProtocolError including data
expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError));
const calledError = resolverMock.mock.calls[0]![0];
expect(calledError.code).toBe(ErrorCode.InvalidParams);
expect(calledError.code).toBe(ProtocolErrorCode.InvalidParams);
expect(calledError.message).toContain('Validation failed');
expect(calledError.data).toEqual({ field: 'userName', reason: 'required' });
});
@ -5356,7 +5358,7 @@ describe('Error handling for missing resolvers', () => {
jsonrpc: '2.0',
id: 999,
error: {
code: ErrorCode.InternalError,
code: ProtocolErrorCode.InternalError,
message: 'Error occurred'
}
},
@ -5414,7 +5416,7 @@ describe('Error handling for missing resolvers', () => {
jsonrpc: '2.0',
id: 2,
error: {
code: ErrorCode.InvalidRequest,
code: ProtocolErrorCode.InvalidRequest,
message: 'Request failed'
}
},
@ -5448,7 +5450,7 @@ describe('Error handling for missing resolvers', () => {
const resolver = testProtocol._requestResolvers.get(requestId);
if (resolver) {
testProtocol._requestResolvers.delete(requestId);
const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
resolver(error);
}
}
@ -5456,12 +5458,12 @@ describe('Error handling for missing resolvers', () => {
// Verify all resolvers were called correctly
expect(resolver1).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
expect(resolver2).toHaveBeenCalledWith(expect.any(McpError));
expect(resolver2).toHaveBeenCalledWith(expect.any(ProtocolError));
expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
// Verify error has correct properties
const error = resolver2.mock.calls[0]![0];
expect(error.code).toBe(ErrorCode.InvalidRequest);
expect(error.code).toBe(ProtocolErrorCode.InvalidRequest);
expect(error.message).toContain('Request failed');
// Verify all resolvers were removed
@ -5523,7 +5525,7 @@ describe('Error handling for missing resolvers', () => {
const resolver = testProtocol._requestResolvers.get(requestId);
if (resolver) {
testProtocol._requestResolvers.delete(requestId);
const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
resolver(error);
}
}

View file

@ -36,14 +36,14 @@ import type {
import {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
ErrorCode,
getObjectShape,
getParseErrorMessage,
getSchemaDescription,
isSchemaOptional,
McpError,
normalizeObjectSchema,
objectFromShape,
ProtocolError,
ProtocolErrorCode,
safeParseAsync,
toJsonSchemaCompat,
UriTemplate,
@ -171,10 +171,10 @@ export class McpServer {
try {
const tool = this._registeredTools[request.params.name];
if (!tool) {
throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`);
}
if (!tool.enabled) {
throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`);
}
const isTaskRequest = !!request.params.task;
@ -183,16 +183,16 @@ export class McpServer {
// Validate task hint configuration
if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) {
throw new McpError(
ErrorCode.InternalError,
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask`
);
}
// Handle taskSupport 'required' without task augmentation
if (taskSupport === 'required' && !isTaskRequest) {
throw new McpError(
ErrorCode.MethodNotFound,
throw new ProtocolError(
ProtocolErrorCode.MethodNotFound,
`Tool ${request.params.name} requires task augmentation (taskSupport: 'required')`
);
}
@ -215,7 +215,7 @@ export class McpServer {
await this.validateToolOutput(tool, result, request.params.name);
return result;
} catch (error) {
if (error instanceof McpError && error.code === ErrorCode.UrlElicitationRequired) {
if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) {
throw error; // Return the error to the caller without wrapping in CallToolResult
}
return this.createToolError(error instanceof Error ? error.message : String(error));
@ -266,7 +266,10 @@ export class McpServer {
if (!parseResult.success) {
const error = 'error' in parseResult ? parseResult.error : 'Unknown error';
const errorMessage = getParseErrorMessage(error);
throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`);
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`
);
}
return parseResult.data as unknown as Args;
@ -290,8 +293,8 @@ export class McpServer {
}
if (!result.structuredContent) {
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Output validation error: Tool ${toolName} has an output schema but no structured content was provided`
);
}
@ -302,8 +305,8 @@ export class McpServer {
if (!parseResult.success) {
const error = 'error' in parseResult ? parseResult.error : 'Unknown error';
const errorMessage = getParseErrorMessage(error);
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}`
);
}
@ -379,7 +382,7 @@ export class McpServer {
await new Promise(resolve => setTimeout(resolve, pollInterval));
const updatedTask = await extra.taskStore.getTask(taskId);
if (!updatedTask) {
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`);
}
task = updatedTask;
}
@ -414,7 +417,7 @@ export class McpServer {
}
default: {
throw new McpError(ErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`);
}
}
});
@ -425,11 +428,11 @@ export class McpServer {
private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise<CompleteResult> {
const prompt = this._registeredPrompts[ref.name];
if (!prompt) {
throw new McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} not found`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`);
}
if (!prompt.enabled) {
throw new McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`);
}
if (!prompt.argsSchema) {
@ -462,7 +465,7 @@ export class McpServer {
return EMPTY_COMPLETION_RESULT;
}
throw new McpError(ErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`);
}
const completer = template.resourceTemplate.completeCallback(request.params.argument.name);
@ -536,7 +539,7 @@ export class McpServer {
const resource = this._registeredResources[uri.toString()];
if (resource) {
if (!resource.enabled) {
throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} disabled`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`);
}
return resource.readCallback(uri, extra);
}
@ -549,7 +552,7 @@ export class McpServer {
}
}
throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} not found`);
});
this._resourceHandlersInitialized = true;
@ -590,11 +593,11 @@ export class McpServer {
this.server.setRequestHandler('prompts/get', async (request, extra): Promise<GetPromptResult> => {
const prompt = this._registeredPrompts[request.params.name];
if (!prompt) {
throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} not found`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`);
}
if (!prompt.enabled) {
throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`);
}
if (prompt.argsSchema) {
@ -603,7 +606,10 @@ export class McpServer {
if (!parseResult.success) {
const error = 'error' in parseResult ? parseResult.error : 'Unknown error';
const errorMessage = getParseErrorMessage(error);
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage}`);
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Invalid arguments for prompt ${request.params.name}: ${errorMessage}`
);
}
const args = parseResult.data;

View file

@ -44,14 +44,16 @@ import {
CreateTaskResultSchema,
ElicitResultSchema,
EmptyResultSchema,
ErrorCode,
LATEST_PROTOCOL_VERSION,
ListRootsResultSchema,
LoggingLevelSchema,
McpError,
mergeCapabilities,
Protocol,
safeParse
ProtocolError,
ProtocolErrorCode,
safeParse,
SdkError,
SdkErrorCode
} from '@modelcontextprotocol/core';
import { ExperimentalServerTasks } from '../experimental/tasks/server.js';
@ -206,7 +208,7 @@ export class Server<
*/
public registerCapabilities(capabilities: ServerCapabilities): void {
if (this.transport) {
throw new Error('Cannot register capabilities after connecting to transport');
throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport');
}
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}
@ -230,7 +232,7 @@ export class Server<
if (!validatedRequest.success) {
const errorMessage =
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`);
}
const { params } = validatedRequest.data;
@ -245,7 +247,7 @@ export class Server<
taskValidationResult.error instanceof Error
? taskValidationResult.error.message
: String(taskValidationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
}
return taskValidationResult.data;
}
@ -255,7 +257,7 @@ export class Server<
if (!validationResult.success) {
const errorMessage =
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`);
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`);
}
return validationResult.data;
@ -273,21 +275,24 @@ export class Server<
switch (method as ServerRequest['method']) {
case 'sampling/createMessage': {
if (!this._clientCapabilities?.sampling) {
throw new Error(`Client does not support sampling (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`);
}
break;
}
case 'elicitation/create': {
if (!this._clientCapabilities?.elicitation) {
throw new Error(`Client does not support elicitation (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`);
}
break;
}
case 'roots/list': {
if (!this._clientCapabilities?.roots) {
throw new Error(`Client does not support listing roots (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support listing roots (required for ${method})`
);
}
break;
}
@ -303,7 +308,7 @@ export class Server<
switch (method as ServerNotification['method']) {
case 'notifications/message': {
if (!this._capabilities.logging) {
throw new Error(`Server does not support logging (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`);
}
break;
}
@ -311,28 +316,40 @@ export class Server<
case 'notifications/resources/updated':
case 'notifications/resources/list_changed': {
if (!this._capabilities.resources) {
throw new Error(`Server does not support notifying about resources (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support notifying about resources (required for ${method})`
);
}
break;
}
case 'notifications/tools/list_changed': {
if (!this._capabilities.tools) {
throw new Error(`Server does not support notifying of tool list changes (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support notifying of tool list changes (required for ${method})`
);
}
break;
}
case 'notifications/prompts/list_changed': {
if (!this._capabilities.prompts) {
throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support notifying of prompt list changes (required for ${method})`
);
}
break;
}
case 'notifications/elicitation/complete': {
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error(`Client does not support URL elicitation (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Client does not support URL elicitation (required for ${method})`
);
}
break;
}
@ -359,14 +376,14 @@ export class Server<
switch (method) {
case 'completion/complete': {
if (!this._capabilities.completions) {
throw new Error(`Server does not support completions (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`);
}
break;
}
case 'logging/setLevel': {
if (!this._capabilities.logging) {
throw new Error(`Server does not support logging (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`);
}
break;
}
@ -374,7 +391,7 @@ export class Server<
case 'prompts/get':
case 'prompts/list': {
if (!this._capabilities.prompts) {
throw new Error(`Server does not support prompts (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`);
}
break;
}
@ -383,7 +400,7 @@ export class Server<
case 'resources/templates/list':
case 'resources/read': {
if (!this._capabilities.resources) {
throw new Error(`Server does not support resources (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`);
}
break;
}
@ -391,7 +408,7 @@ export class Server<
case 'tools/call':
case 'tools/list': {
if (!this._capabilities.tools) {
throw new Error(`Server does not support tools (required for ${method})`);
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`);
}
break;
}
@ -401,7 +418,10 @@ export class Server<
case 'tasks/result':
case 'tasks/cancel': {
if (!this._capabilities.tasks) {
throw new Error(`Server does not support tasks capability (required for ${method})`);
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support tasks capability (required for ${method})`
);
}
break;
}
@ -496,7 +516,7 @@ export class Server<
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
// Capability check - only required when tools/toolChoice are provided
if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) {
throw new Error('Client does not support sampling tools capability.');
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.');
}
// Message structure validation - always validate tool_use/tool_result pairs.
@ -517,10 +537,16 @@ export class Server<
if (hasToolResults) {
if (lastContent.some(c => c.type !== 'tool_result')) {
throw new Error('The last message must contain only tool_result content if any is present');
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'The last message must contain only tool_result content if any is present'
);
}
if (!hasPreviousToolUse) {
throw new Error('tool_result blocks are not matching any tool_use from the previous message');
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'tool_result blocks are not matching any tool_use from the previous message'
);
}
}
if (hasPreviousToolUse) {
@ -529,7 +555,10 @@ export class Server<
lastContent.filter(c => c.type === 'tool_result').map(c => (c as ToolResultContent).toolUseId)
);
if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) {
throw new Error('ids of tool_result blocks and tool_use blocks from previous message do not match');
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'ids of tool_result blocks and tool_use blocks from previous message do not match'
);
}
}
}
@ -554,7 +583,7 @@ export class Server<
switch (mode) {
case 'url': {
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error('Client does not support url elicitation.');
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.');
}
const urlParams = params as ElicitRequestURLParams;
@ -562,7 +591,7 @@ export class Server<
}
case 'form': {
if (!this._clientCapabilities?.elicitation?.form) {
throw new Error('Client does not support form elicitation.');
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.');
}
const formParams: ElicitRequestFormParams =
@ -576,17 +605,17 @@ export class Server<
const validationResult = validator(result.content);
if (!validationResult.valid) {
throw new McpError(
ErrorCode.InvalidParams,
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Elicitation response content does not match requested schema: ${validationResult.errorMessage}`
);
}
} catch (error) {
if (error instanceof McpError) {
if (error instanceof ProtocolError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}`
);
}
@ -606,7 +635,10 @@ export class Server<
*/
createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise<void> {
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error('Client does not support URL elicitation (required for notifications/elicitation/complete)');
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
'Client does not support URL elicitation (required for notifications/elicitation/complete)'
);
}
return () =>