| name | api-errors |
| description | McpError constructor, JsonRpcErrorCode reference, and error handling patterns for `@cyanheads/mcp-ts-core`. Use when looking up error codes, understanding where errors should be thrown vs. caught, or using ErrorHandler.tryCatch in services.
|
| metadata | {"author":"cyanheads","version":"1.7","audience":"external","type":"reference"} |
Overview
Error handling in @cyanheads/mcp-ts-core follows a strict layered pattern: tool and resource handlers throw McpError freely (no try/catch), the handler factory catches and normalizes all errors, and services use ErrorHandler.tryCatch for structured logging and wrapping.
Imports:
import { notFound, validationError, McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
import { ErrorHandler } from '@cyanheads/mcp-ts-core/utils';
Type-Driven Error Contract (recommended)
The recommended path for new tools and resources. Declare failure modes as a const tuple under errors; the reason union flows into the handler's ctx.fail and TypeScript enforces that you can only fail with a declared reason:
import { tool, z } from '@cyanheads/mcp-ts-core';
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
export const fetchTool = tool('fetch_articles', {
description: 'Fetch articles by PMID',
input: z.object({ pmids: z.array(z.string()).describe('PMIDs') }),
output: z.object({ articles: z.array(z.unknown()).describe('Articles') }),
errors: [
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound,
when: 'No requested PMID returned data',
recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
when: 'Local request queue is at capacity', retryable: true,
recovery: 'Wait 30 seconds and retry, or reduce batch size.' },
{ reason: 'ncbi_down', code: JsonRpcErrorCode.ServiceUnavailable,
when: 'NCBI E-utilities unreachable after retries', retryable: true,
recovery: 'NCBI is degraded; retry in a few minutes.' },
],
async handler(input, ctx) {
const articles = await ncbi.fetch(input.pmids);
if (articles.length === 0) {
throw ctx.fail('no_match', `None of ${input.pmids.length} PMIDs returned data`);
}
return { articles };
},
});
What you get:
| Surface | Behavior |
|---|
| Compile time | ctx.fail('typo') is a TS error. Auto-completes declared reasons. |
| Runtime | ctx.fail(reason, msg?, data?, options?) builds an McpError(contract.code, msg, { ...data, reason }, options) — data.reason is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then reason written last), so observers see a stable identifier. options accepts { cause } for ES2022 error chaining. |
| Lint (devcheck) | Each code validated against JsonRpcErrorCode. Reasons validated as snake_case + unique within contract. recovery validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
| Lint (conformance) | If the handler throw new McpError(JsonRpcErrorCode.X) outside ctx.fail, conformance check warns when X isn't declared. |
recovery is opt-in resolution, not auto-population. The contract recovery is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter). It does not automatically appear in runtime data.recovery.hint — the framework never injects it without an explicit signal at the throw site. Authors opt in by spreading ctx.recoveryFor('reason') into the data argument, the same way ctx.fail('reason') opts into resolving the contract code. What the author types at the throw site is what flows to the wire, with no hidden transformation; the resolver is just a typed lookup keyed by the same reason the author already typed.
ctx.recoveryFor — opt-in contract resolution
ctx.recoveryFor(reason) returns { recovery: { hint: <contract.recovery> } } for a declared reason, ready to spread into data. Always available on Context (returns {} when no contract is attached or the reason is unknown — spread-safe with no optional chaining). On HandlerContext<R> it tightens to a typed signature constrained to the declared reason union.
export const calculateTool = tool('calculate', {
errors: [
{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
when: 'Expression is empty or whitespace-only.',
recovery: 'Provide a non-empty mathematical expression to evaluate.' },
],
handler(input, ctx) {
if (!input.expression.trim()) {
throw ctx.fail('empty_expression', undefined, { ...ctx.recoveryFor('empty_expression') });
}
},
});
Same pattern works inside services that accept ctx:
export class MathService {
parse(expr: string, ctx: Context) {
try {
return mathjs.parse(expr);
} catch (err) {
throw validationError(`Parse failed: ${err.message}`, {
reason: 'parse_failed',
...ctx.recoveryFor('parse_failed'),
});
}
}
}
The contract is the single source of truth — write the recovery once, lint validates ≥5 words, the resolver carries it to every throw site that opts in. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
throw ctx.fail('no_match', `No item ${id}`, {
recovery: { hint: `No item ${id}; try IDs 1-100 instead.` },
});
ctx.recoveryFor is the first member of a planned family of opt-in resolution helpers. Future contract-bound fields (troubleshootingFor, userMessageFor, …) follow the same shape: single-purpose, spreadable wire-shape, {} fallback when not applicable.
Skip the contract for one-off internal tools or quick prototypes — ctx is plain Context (no fail) and you throw via factories directly. Behavior is identical at the wire; the contract just adds compile-time safety.
Declare contracts inline on each tool, even when similar across tools. The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture (input, output, errors, handler, format). Don't extract a shared errors[] constant or contract module to deduplicate near-identical entries; per-tool repetition is the intended cost of locality, and dynamic recovery hints often need tool-specific runtime context anyway. If a code-cleanup pass suggests consolidating contracts, decline — the duplication is load-bearing for tool-def readability.
Limits of the conformance lint. The conformance and prefer-fail rules scan the handler's source text for throw statements. Errors thrown from called services (e.g. await myService.fetch() raising RateLimited internally) are invisible — the lint only sees what's lexically in the handler. Treat the contract as the advertised failure surface; bubbled-up codes still reach the client correctly via the auto-classifier, just without lint enforcement.
Carrying contract reason from services
Services don't receive ctx automatically (unlike handlers), so they can't call ctx.fail directly — though ctx can be passed as a parameter when needed. To make a service-thrown failure carry the contract's reason on the wire, pass data: { reason: 'X' } to the factory. The framework's auto-classifier preserves data unchanged, so clients see the same error.data.reason they'd see from ctx.fail:
throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
throw serviceUnavailable('Upstream timeout', { reason: 'evaluation_timeout' });
errors: [
{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
when: 'Input is empty.',
recovery: 'Provide a non-empty expression to evaluate.' },
{ reason: 'evaluation_timeout', code: JsonRpcErrorCode.ServiceUnavailable,
when: 'Upstream exceeded the configured timeout.',
recovery: 'Simplify the expression or retry the request after a brief delay.' },
]
The handler doesn't catch and re-throw — letting service errors bubble unchanged keeps "logic throws, framework catches" intact. The wire payload still carries code + data.reason, and clients can switch on reason without parsing message text. What's lost is lint-time enforcement that every reason is reachable; compensate with one wire-shape test per reason.
To carry the contract recovery from a service throw, accept ctx and spread the resolver:
throw validationError(message, {
reason: 'parse_failed',
...ctx.recoveryFor('parse_failed'),
});
ctx.recoveryFor is always present on Context (no-op when no contract), so services don't need to know which tool called them — the spread is safe either way.
When not to throw
Throw when the server has authoritative classification — auth failure, rate limit, schema violation, upstream 5xx, missing required input. Don't throw when "this looks wrong" depends on intent the server can't see. For mutators, surface raw pre- and post-mutation observable state in the response and let the agent decide whether it matches intent — the server can detect that the file shrunk, but only the agent knows whether it was supposed to. Tell: defensive code justified as a free rider on other work — audit it standalone, and it usually doesn't earn its keep.
Error Factories (fallback)
Use when no contract entry fits — ad-hoc throws, tools without a contract, or service-layer code. Shorter than new McpError(...) and self-documenting. All return McpError instances and accept an optional options parameter for error chaining via { cause }.
throw notFound('Item not found', { itemId: '123' });
throw validationError('Missing required field: name', { field: 'name' });
throw unauthorized('Token expired');
throw serviceUnavailable('API call failed', { url }, { cause: error });
Available factories:
| Factory | Code |
|---|
invalidParams(msg, data?, options?) | InvalidParams (-32602) |
invalidRequest(msg, data?, options?) | InvalidRequest (-32600) |
notFound(msg, data?, options?) | NotFound (-32001) |
forbidden(msg, data?, options?) | Forbidden (-32005) |
unauthorized(msg, data?, options?) | Unauthorized (-32006) |
validationError(msg, data?, options?) | ValidationError (-32007) |
conflict(msg, data?, options?) | Conflict (-32002) |
rateLimited(msg, data?, options?) | RateLimited (-32003) |
timeout(msg, data?, options?) | Timeout (-32004) |
serviceUnavailable(msg, data?, options?) | ServiceUnavailable (-32000) |
configurationError(msg, data?, options?) | ConfigurationError (-32008) |
internalError(msg, data?, options?) | InternalError (-32603) |
serializationError(msg, data?, options?) | SerializationError (-32070) — JSON/XML/parser failures |
databaseError(msg, data?, options?) | DatabaseError (-32010) |
options is { cause?: unknown } — the standard ES2022 ErrorOptions type.
McpError Constructor
For codes not covered by factories (rare — MethodNotFound, ParseError, InitializationFailed, UnknownError):
throw new McpError(code, message?, data?, options?)
code — a JsonRpcErrorCode enum value
message — optional human-readable description of the failure
data — optional structured context (plain object)
options — optional { cause?: unknown } for error chaining
Example:
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection pool exhausted', {
pool: 'primary',
});
Error Codes
Standard JSON-RPC 2.0 codes:
| Code | Value | When to Use |
|---|
ParseError | -32700 | Malformed JSON received |
InvalidRequest | -32600 | Unsupported operation, missing client capability |
MethodNotFound | -32601 | Requested method does not exist |
InvalidParams | -32602 | Bad input, missing required fields, schema validation failure |
InternalError | -32603 | Unexpected failure, catch-all for programmer errors |
Implementation-defined codes (-32000 to -32099):
| Code | Value | When to Use |
|---|
ServiceUnavailable | -32000 | External dependency down, upstream failure |
NotFound | -32001 | Resource, entity, or record doesn't exist |
Conflict | -32002 | Duplicate key, version mismatch, concurrent modification |
RateLimited | -32003 | Rate limit exceeded |
Timeout | -32004 | Operation exceeded time limit |
Forbidden | -32005 | Authenticated but insufficient scopes/permissions |
Unauthorized | -32006 | No auth, invalid token, expired credentials |
ValidationError | -32007 | Business rule violation (not schema — use InvalidParams for that) |
ConfigurationError | -32008 | Missing env var, invalid config |
InitializationFailed | -32009 | Server/component startup failure |
DatabaseError | -32010 | Storage/persistence layer failure |
SerializationError | -32070 | Data serialization/deserialization failed |
UnknownError | -32099 | Generic fallback when no other code fits |
Auto-Classification
When a handler throws a plain Error (or any non-McpError value), the framework classifies it to the most specific JsonRpcErrorCode automatically. This matters when you don't control what a third-party library throws and can't predict its error type.
Use factories or McpError directly when the code must be exact — auto-classification is best-effort pattern matching and not guaranteed for ambiguous messages. For errors from your own code where the code matters, be explicit.
Resolution Order
The framework applies these steps in order — first match wins:
McpError instance — error.code is preserved as-is; no classification needed.