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
7 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)}`
)
};