Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ authorization policy, and business-scope narrowing before execution.

## Tools

`tools/call` for a registered tool runs input validation → authorization policy → handler, and maps
failures to a tool result with `isError` and a `{ code, message, correlationId, retryable? }`
payload (spec §10.2): `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERROR`, `TIMEOUT_ERROR`,
`INTERNAL_ERROR`.
`tools/call` for a registered tool runs input validation → authorization policy → handler. Every
failure is normalized through a single error taxonomy (`src/errors/taxonomy.ts`) and returned as a
tool result with `isError` and a `{ code, message, correlationId, retryable }` payload (plus
`issues` for validation and `retryAfterMs` for rate limiting). Machine codes (spec §10.2):
`VALIDATION_ERROR`, `AUTHENTICATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERROR`,
`TIMEOUT_ERROR`, `RATE_LIMIT_ERROR`, `INTERNAL_ERROR`. Unexpected errors map to a sanitized
`INTERNAL_ERROR` — stack traces and internal details are never leaked.

List-producing tools build their output through a shared formatter (`src/tools/output.ts`) that caps
the serialized payload (dropping whole trailing items — never invalid JSON), reports `returnedCount`
Expand Down
124 changes: 124 additions & 0 deletions packages/mcp-server/src/errors/__tests__/taxonomy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { TokenVerificationError } from '../../auth/token.js';
import { UpstreamError } from '../../upstream/graphql-client.js';
import {
DEFAULT_RETRYABLE,
errorPayload,
isInternalError,
McpError,
RateLimitError,
ToolInputError,
toErrorPayload,
toToolErrorResult,
} from '../taxonomy.js';

const CID = 'corr-1';

describe('errorPayload', () => {
it('defaults retryability by code', () => {
expect(errorPayload('TIMEOUT_ERROR', 'timed out', CID).retryable).toBe(true);
expect(errorPayload('VALIDATION_ERROR', 'bad', CID).retryable).toBe(false);
});

it('honors an explicit retryable override and includes issues', () => {
const payload = errorPayload('VALIDATION_ERROR', 'bad', CID, {
retryable: true,
issues: [{ path: 'x', message: 'required' }],
});
// VALIDATION_ERROR defaults to non-retryable; the explicit override wins.
expect(payload.retryable).toBe(true);
expect(payload.issues).toEqual([{ path: 'x', message: 'required' }]);
});

it('omits empty issues and absent retryAfterMs', () => {
const payload = errorPayload('INTERNAL_ERROR', 'x', CID, { issues: [] });
expect('issues' in payload).toBe(false);
expect('retryAfterMs' in payload).toBe(false);
});
});

describe('toErrorPayload — mapping every source', () => {
it('passes through an McpError with its fields', () => {
const payload = toErrorPayload(
new McpError('AUTHORIZATION_ERROR', 'nope', { retryable: false }),
CID,
);
expect(payload).toMatchObject({ code: 'AUTHORIZATION_ERROR', message: 'nope', retryable: false });
});

it('maps ToolInputError to VALIDATION_ERROR with issues', () => {
const payload = toErrorPayload(new ToolInputError('bad range', [{ path: 'to', message: 'x' }]), CID);
expect(payload.code).toBe('VALIDATION_ERROR');
expect(payload.issues).toEqual([{ path: 'to', message: 'x' }]);
});

it('maps RateLimitError to RATE_LIMIT_ERROR with retryAfterMs', () => {
const payload = toErrorPayload(new RateLimitError('slow down', 5000), CID);
expect(payload).toMatchObject({ code: 'RATE_LIMIT_ERROR', retryable: true, retryAfterMs: 5000 });
});

it('maps UpstreamError preserving its code and retryability', () => {
expect(toErrorPayload(new UpstreamError('TIMEOUT_ERROR', 'slow', true), CID)).toMatchObject({
code: 'TIMEOUT_ERROR',
retryable: true,
});
expect(toErrorPayload(new UpstreamError('UPSTREAM_ERROR', 'boom', false), CID)).toMatchObject({
code: 'UPSTREAM_ERROR',
retryable: false,
});
});

it('maps a token verification failure to AUTHENTICATION_ERROR', () => {
const payload = toErrorPayload(new TokenVerificationError('expired'), CID);
expect(payload).toMatchObject({ code: 'AUTHENTICATION_ERROR', retryable: false });
});

it('maps an unknown error to a sanitized INTERNAL_ERROR (no leak)', () => {
const payload = toErrorPayload(new Error('SELECT * FROM secrets failed at line 42'), CID);
expect(payload.code).toBe('INTERNAL_ERROR');
expect(payload.message).not.toContain('SELECT');
expect(payload.correlationId).toBe(CID);
});

it('maps a non-Error throwable to INTERNAL_ERROR', () => {
expect(toErrorPayload('boom', CID).code).toBe('INTERNAL_ERROR');
});
});

describe('isInternalError', () => {
it('is true only for unmapped errors', () => {
expect(isInternalError(new Error('x'))).toBe(true);
expect(isInternalError('x')).toBe(true);
expect(isInternalError(new McpError('AUTHORIZATION_ERROR', 'x'))).toBe(false);
expect(isInternalError(new UpstreamError('UPSTREAM_ERROR', 'x', false))).toBe(false);
expect(isInternalError(new TokenVerificationError('x'))).toBe(false);
});
});

describe('toToolErrorResult', () => {
it('renders isError with a code-prefixed text and structured payload', () => {
const result = toToolErrorResult(
errorPayload('RATE_LIMIT_ERROR', 'slow down', CID, { retryAfterMs: 1000 }),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('RATE_LIMIT_ERROR: slow down');
expect(result.structuredContent).toMatchObject({
code: 'RATE_LIMIT_ERROR',
message: 'slow down',
correlationId: CID,
retryable: true,
retryAfterMs: 1000,
});
});
});

describe('DEFAULT_RETRYABLE', () => {
it('marks only transient failures retryable', () => {
expect(DEFAULT_RETRYABLE.TIMEOUT_ERROR).toBe(true);
expect(DEFAULT_RETRYABLE.RATE_LIMIT_ERROR).toBe(true);
expect(DEFAULT_RETRYABLE.VALIDATION_ERROR).toBe(false);
expect(DEFAULT_RETRYABLE.AUTHENTICATION_ERROR).toBe(false);
expect(DEFAULT_RETRYABLE.AUTHORIZATION_ERROR).toBe(false);
expect(DEFAULT_RETRYABLE.INTERNAL_ERROR).toBe(false);
});
});
167 changes: 167 additions & 0 deletions packages/mcp-server/src/errors/taxonomy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { TokenVerificationError } from '../auth/token.js';
import type { ToolResult, ToolValidationIssue } from '../tools/registry.js';
import { UpstreamError } from '../upstream/graphql-client.js';

/**
* Unified error taxonomy and mapper (spec §10.2).
*
* Every failure surfaced to a caller is normalized to one machine {@link
* McpErrorCode} with a business-safe message, the request correlation id, and a
* retryability hint. Stack traces and internal/SQL details are never included.
* All error sources (validation, auth, policy, upstream, rate limiting, and
* unexpected failures) map through {@link toErrorPayload}.
*/

export type McpErrorCode =
| 'VALIDATION_ERROR'
| 'AUTHENTICATION_ERROR'
| 'AUTHORIZATION_ERROR'
| 'UPSTREAM_ERROR'
| 'TIMEOUT_ERROR'
| 'RATE_LIMIT_ERROR'
| 'INTERNAL_ERROR';

/** Default retryability per code (transient failures are retryable). */
export const DEFAULT_RETRYABLE: Record<McpErrorCode, boolean> = {
VALIDATION_ERROR: false,
AUTHENTICATION_ERROR: false,
AUTHORIZATION_ERROR: false,
UPSTREAM_ERROR: false,
TIMEOUT_ERROR: true,
RATE_LIMIT_ERROR: true,
INTERNAL_ERROR: false,
};

/** Normalized, business-safe error payload returned to callers. */
export interface McpErrorPayload {
code: McpErrorCode;
/** Human-readable message safe to show end users (no internal detail). */
message: string;
correlationId: string;
/** Whether the caller may retry the same request. */
retryable: boolean;
/** Field-level issues for VALIDATION_ERROR. */
issues?: ToolValidationIssue[];
/** Suggested wait before retrying (rate limiting / backoff). */
retryAfterMs?: number;
}

export interface McpErrorOptions {
retryable?: boolean;
issues?: ToolValidationIssue[];
retryAfterMs?: number;
}

/** Base error carrying a taxonomy code. Thrown by layers that fail loudly. */
export class McpError extends Error {
public readonly code: McpErrorCode;
public readonly retryable: boolean;
public readonly issues?: ToolValidationIssue[];
public readonly retryAfterMs?: number;

constructor(code: McpErrorCode, message: string, options: McpErrorOptions = {}) {
super(message);
this.name = 'McpError';
this.code = code;
this.retryable = options.retryable ?? DEFAULT_RETRYABLE[code];
this.issues = options.issues;
this.retryAfterMs = options.retryAfterMs;
}
}

/**
* A domain-validation failure a handler can throw (e.g. cross-field bounds not
* expressible in the input schema). Maps to VALIDATION_ERROR.
*/
export class ToolInputError extends McpError {
constructor(message: string, issues?: ToolValidationIssue[]) {
super('VALIDATION_ERROR', message, { issues });
this.name = 'ToolInputError';
}
}

/** Raised when a caller exceeds a rate limit. Maps to RATE_LIMIT_ERROR. */
export class RateLimitError extends McpError {
constructor(message: string, retryAfterMs: number) {
super('RATE_LIMIT_ERROR', message, { retryable: true, retryAfterMs });
this.name = 'RateLimitError';
}
}

const SAFE_INTERNAL_MESSAGE = 'An internal error occurred';

function withOptionalFields(payload: McpErrorPayload): McpErrorPayload {
const result: McpErrorPayload = {
code: payload.code,
message: payload.message,
correlationId: payload.correlationId,
retryable: payload.retryable,
};
if (payload.issues && payload.issues.length > 0) {
result.issues = payload.issues;
}
if (payload.retryAfterMs !== undefined) {
result.retryAfterMs = payload.retryAfterMs;
}
return result;
}

/** Build a payload from explicit fields, defaulting retryability by code. */
export function errorPayload(
code: McpErrorCode,
message: string,
correlationId: string,
options: McpErrorOptions = {},
): McpErrorPayload {
return withOptionalFields({
code,
message,
correlationId,
retryable: options.retryable ?? DEFAULT_RETRYABLE[code],
issues: options.issues,
retryAfterMs: options.retryAfterMs,
});
}

/**
* Map any error source into the taxonomy. Known errors keep their code and
* message; anything else becomes an INTERNAL_ERROR with a generic message so no
* stack trace or internal detail leaks.
*/
export function toErrorPayload(error: unknown, correlationId: string): McpErrorPayload {
if (error instanceof McpError) {
return withOptionalFields({
code: error.code,
message: error.message,
correlationId,
retryable: error.retryable,
issues: error.issues,
retryAfterMs: error.retryAfterMs,
});
}
if (error instanceof UpstreamError) {
return errorPayload(error.code, error.message, correlationId, { retryable: error.retryable });
}
if (error instanceof TokenVerificationError) {
return errorPayload('AUTHENTICATION_ERROR', error.message, correlationId);
}
return errorPayload('INTERNAL_ERROR', SAFE_INTERNAL_MESSAGE, correlationId);
}

/** Whether an error maps to INTERNAL_ERROR (i.e. it is unexpected/unmapped). */
export function isInternalError(error: unknown): boolean {
return !(
error instanceof McpError ||
error instanceof UpstreamError ||
error instanceof TokenVerificationError
);
}

/** Render an error payload as an MCP tool result (`isError: true`). */
export function toToolErrorResult(payload: McpErrorPayload): ToolResult {
return {
content: [{ type: 'text', text: `${payload.code}: ${payload.message}` }],
isError: true,
structuredContent: withOptionalFields(payload),
};
}
Loading
Loading