diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8f9c0e9bd..7ff3965a4 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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` diff --git a/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts b/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts new file mode 100644 index 000000000..7fe7cd595 --- /dev/null +++ b/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts @@ -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); + }); +}); diff --git a/packages/mcp-server/src/errors/taxonomy.ts b/packages/mcp-server/src/errors/taxonomy.ts new file mode 100644 index 000000000..3056a59c1 --- /dev/null +++ b/packages/mcp-server/src/errors/taxonomy.ts @@ -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 = { + 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), + }; +} diff --git a/packages/mcp-server/src/tools/execute.ts b/packages/mcp-server/src/tools/execute.ts index aee799ea3..292f0e022 100644 --- a/packages/mcp-server/src/tools/execute.ts +++ b/packages/mcp-server/src/tools/execute.ts @@ -1,6 +1,11 @@ import type { McpAuthContext } from '../auth/identity.js'; +import { + errorPayload, + isInternalError, + toErrorPayload, + toToolErrorResult, +} from '../errors/taxonomy.js'; import { log } from '../logger.js'; -import { UpstreamError } from '../upstream/graphql-client.js'; import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js'; import { evaluateToolPolicy } from './policy.js'; import { @@ -8,65 +13,20 @@ import { type ToolDefinition, type ToolExecutionContext, type ToolResult, - type ToolValidationIssue, } from './registry.js'; /** - * Curated tool execution: input validation → authorization policy → handler, - * with deterministic error mapping to the spec's error taxonomy (§10.2). + * Curated tool execution: input validation → authorization policy → handler. * - * Tool-execution failures are returned as an MCP tool result with `isError` - * and a structured `{ code, message, correlationId, retryable? }` payload (the - * unified error mapper in a later step formalizes this shape) rather than as - * protocol-level JSON-RPC errors, so the model can read them. - */ - -/** Machine error codes surfaced to tool callers (spec §10.2). */ -export type ToolErrorCode = - | 'VALIDATION_ERROR' - | 'AUTHORIZATION_ERROR' - | 'UPSTREAM_ERROR' - | 'TIMEOUT_ERROR' - | 'INTERNAL_ERROR'; - -/** - * A domain-validation failure a handler can throw (e.g. cross-field bounds not - * expressible in the input schema). Mapped to a VALIDATION_ERROR result. + * Every failure is normalized through the unified error taxonomy + * ({@link toErrorPayload}) and returned as an MCP tool result with `isError` + * and a structured `{ code, message, correlationId, retryable }` payload rather + * than as a protocol-level JSON-RPC error, so the model can read it. */ -export class ToolInputError extends Error { - public readonly code = 'VALIDATION_ERROR'; - - constructor( - message: string, - public readonly issues?: ToolValidationIssue[], - ) { - super(message); - this.name = 'ToolInputError'; - } -} -interface ToolErrorDetails { - code: ToolErrorCode; - message: string; - correlationId: string; - retryable?: boolean; - issues?: ToolValidationIssue[]; -} - -/** Build an MCP tool-result error carrying the taxonomy fields. */ -export function toolErrorResult(details: ToolErrorDetails): ToolResult { - return { - content: [{ type: 'text', text: `${details.code}: ${details.message}` }], - isError: true, - structuredContent: { - code: details.code, - message: details.message, - correlationId: details.correlationId, - ...(details.retryable !== undefined && { retryable: details.retryable }), - ...(details.issues && details.issues.length > 0 && { issues: details.issues }), - }, - }; -} +// Re-exported so tool handlers can import the domain-validation error alongside +// the executor; the canonical definition lives in the error taxonomy. +export { ToolInputError } from '../errors/taxonomy.js'; /** * Optional per-tool convention: input fields carrying scope narrowing. Either a @@ -105,12 +65,11 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< // 1. Strict input validation (unknown fields rejected). const validation = validateToolInput(tool, rawArgs); if (!validation.ok) { - return toolErrorResult({ - code: 'VALIDATION_ERROR', - message: validation.error.message, - correlationId, - issues: validation.error.issues, - }); + return toToolErrorResult( + errorPayload('VALIDATION_ERROR', validation.error.message, correlationId, { + issues: validation.error.issues, + }), + ); } const input = validation.data; @@ -121,11 +80,9 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< requestedBusinessIds: requestedBusinessIds(input), }); if (!decision.allowed) { - return toolErrorResult({ - code: 'AUTHORIZATION_ERROR', - message: decision.error.message, - correlationId, - }); + return toToolErrorResult( + errorPayload('AUTHORIZATION_ERROR', decision.error.message, correlationId), + ); } // 3. Execute the handler. @@ -139,35 +96,16 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< try { return await tool.handler(input, context); } catch (error) { - if (error instanceof ToolInputError) { - return toolErrorResult({ - code: 'VALIDATION_ERROR', - message: error.message, - correlationId, - issues: error.issues, - }); - } - if (error instanceof UpstreamError) { - return toolErrorResult({ - code: error.code, - message: error.message, + // Log unexpected (unmapped) failures so bugs aren't hidden; the caller only + // ever sees the generic, sanitized INTERNAL_ERROR message. + if (isInternalError(error)) { + log('error', 'unexpected error during tool execution', { + tool: tool.name, correlationId, - retryable: error.retryable, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, }); } - // Unexpected failure — log it (with the tool + correlation id) so the - // observability gap doesn't hide production bugs; the caller only sees a - // generic message. - log('error', 'unexpected error during tool execution', { - tool: tool.name, - correlationId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - return toolErrorResult({ - code: 'INTERNAL_ERROR', - message: 'Tool execution failed', - correlationId, - }); + return toToolErrorResult(toErrorPayload(error, correlationId)); } }