- 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.23","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:**
```ts
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:
```ts
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`);
}
// ctx.fail('typo') ← TypeScript error: 'typo' isn't in the contract
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. Its stack starts at the line that called `ctx.fail`, with the framework's own frame cut, as an error factory's does. |
| Runtime (recovery) | A failure whose `data.reason` names a declared entry and carries no `data.recovery` gets `data.recovery.hint` set to the entry's `recovery` at the handler boundary — see below. |
| 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. The inverse is checked too: a declared reason no `ctx.fail` in the handler names warns as `error-contract-unthrown` (mark it `thrownBy: 'service'` when the service layer produces it). |
> **`recovery` is the wire default for its reason.** 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), and it is what the caller receives. When a failure whose `data.reason` names a declared entry reaches the tool or resource handler factory with no `data.recovery`, the factory sets `data.recovery.hint` to that entry's `recovery` before it logs the failure and builds the envelope, so the `Error in tool:<name>` record, `structuredContent.error.data`, and the `Recovery:` line in `content[]` carry the same hint. It matches on the reason alone — a bare `ctx.fail('reason')`, a service throwing `notFound(msg, { reason })`, and a declared reason raised through a factory with a different code all get it. A throw-site `recovery` always wins, whatever its shape. An undeclared reason, a tool without `errors[]`, a non-`McpError` throw, and a cancelled call get nothing, and the framework-owned `invalid_arguments` / `client_capability_missing` refusals keep their own hints. The thrown `McpError` is never changed — a handler-level test of `ctx.fail` sees exactly what the throw site wrote — and `runToolContract` applies the same fill, so a contract test sees the production envelope. Prompts declare no contract.
```ts
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()) {
// Static recovery — the framework fills the contract's hint onto the wire.
throw ctx.fail('empty_expression');
}
// ...
},
});
```
Same for a service, which needs no `ctx` to get the hint — the reason is enough:
```ts
export class MathService {
parse(expr: string) {
try {
return mathjs.parse(expr);
} catch (err) {
throw validationError(`Parse failed: ${err.message}`, { reason: 'parse_failed' });
}
}
}
```
The contract is the single source of truth — write the recovery once, lint validates ≥5 words, and the framework carries it to every failure with that reason. For runtime-context recovery (interpolating input values, attempted IDs, queue state), override at the throw site:
```ts
throw ctx.fail('no_match', `No item ${id}`, {
recovery: { hint: `No item ${id}; try IDs 1-100 instead.` },
});
```
> **A recovery hint names a capability, never an internal method.** The reader is a model whose only reachable surface is this server's tool names — it cannot call a TypeScript method, set a library option, or re-run an internal function. `Re-stage the table via registerTable()` is unfollowable and invites a hallucinated tool call; `Re-run the tool that produced this table to stage it again, or list the currently staged tables with this server's dataframe-describe tool` is actionable from where the reader sits. Name a condition the caller cannot observe — an option flag they never set — and the hint is noise for the same reason. The framework holds its own throws to this rule: the canvas SQL gate's rejections point at the dataframe-query and dataframe-describe capabilities rather than the provider methods behind them.
#### `ctx.recoveryFor` — the entry's hint at the throw site
`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.
It is not needed to put a declared hint on the wire — the fill above does that. Reach for it when the hint has to ride the thrown error itself: a test asserting `data.recovery` on the handler's own throw, or a site that deliberately sends another entry's guidance (`ctx.fail('a', msg, ctx.recoveryFor('b'))`), which the fill respects as authored.
#### `severity` — log a modeled outcome below `error`
An outcome a tool declares in `errors[]` is a modeled result, not an incident. A caller who answers no to a confirmation prompt, a lookup whose miss is an ordinary answer — logging those at `error` alongside upstream faults and bugs leaves the error stream unreadable at the level log-based alerting works on. `severity` moves that one record's level:
```ts
errors: [
{ reason: 'consent_declined', code: JsonRpcErrorCode.InvalidRequest,
when: 'The caller declined the confirmation prompt.', severity: 'notice',
recovery: 'Re-run the tool and confirm the prompt to proceed with the change.' },
],
```
Values are the logger's own level names below `error` — `debug`, `info`, `notice`, `warning`. Omitting the field keeps `error`, byte for byte, for every server that does not opt in.
| Surface | Under a declared severity |
|:--------|:--------------------------|
| The `Error in tool:<name>` log record | Emitted at the declared level. Same message, same structured fields, the throw site's stack included — except the framework's own refusals, whose records carry no stack, an argument rejection's bounded as well (see below). |
| `mcp.errors.classified` | Gains an `mcp.error.severity` attribute. The `reason` itself never becomes a metric attribute. |
| `isError`, the JSON-RPC code, `structuredContent.error`, `content[]` | Byte-identical to the undeclared case. |
| Span status, `mcp.tool.calls`, `mcp.tool.duration`, `mcp.tool.errors` | Unchanged — the call still failed, and splitting those series would redefine what an error rate means. |
**Tools only.** Resolution happens in the tool handler factory, against the thrown error's `data.reason` — the same reason-to-entry lookup that fills `data.recovery`. Resources declare `errors[]` but write no failure record, so the field is accepted there and inert. A reason thrown below the handler that the contract never declared, an entry with no `severity`, and a non-`McpError` throw all keep `error`. A cancelled request keeps its own `info`, stack-free path regardless.
**The framework's own refusals log at `notice`.** An argument rejection (`invalid_arguments`, raised only by the schema gate before the handler runs), a `ctx.requestInput` the connection cannot serve (`client_capability_missing`), and a missing-scope refusal (the `Forbidden` the inline `auth` check or `checkScopes` throws) are routine caller or connection traffic, not server faults, so their `Error in tool:<name>` record — and the failure-payload record when `LOG_TOOL_FAILURE_PAYLOADS=true` — is emitted at `notice`, and `mcp.errors.classified` counts them with `mcp.error.severity: "notice"`. Nothing to declare; an `errors[]` entry naming `invalid_arguments` or `client_capability_missing` with its own `severity` still wins, while a missing-scope refusal carries no `data.reason`, so its level is fixed. That refusal is recognized by where it was raised, never by its code: a handler's own `forbidden()`, an upstream 403 mapped by `httpErrorFromResponse`, and a missing auth context (`Unauthorized`) keep `error` and the stack. All three records log no stack, whatever level an entry declares, and an argument rejection's record is bounded whatever the caller sends: the message, `recovery.hint`, each issue's `message`, each `data.input` key, and every other string and key keep at most their first 1,024 characters, every array its first 10 entries, and every object its first 10 keys, with the uncut length or count beside each cut (`originalMessageLength`, `<field>Length`, `<field>KeyLength`, `<field>Count`, `<field>Lengths`). The `-32602` result is bounded the same way (see `invalid_arguments` below), so the record cuts only its message and hint further. `mcp.tool.rejections` is unchanged, and a schema that wrongly rejects valid calls still shows per tool on it.
**Skip the contract** for one-off internal tools or quick prototypes — `ctx` is plain `Context` (no `fail`) and you throw via [factories](#error-factories-fallback) 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`:
```ts
// my-service.ts
throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
throw serviceUnavailable('Upstream timeout', { reason: 'evaluation_timeout' });
```
```ts
// my-tool.tool.ts
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 carries `code`, `data.reason`, and the declared entry's `recovery` as `data.recovery.hint` (filled at the handler boundary, whatever code the service picked), so 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.
**Mark the entries the service produces.** `error-contract-unthrown` reads the handler body alone, so in a handler that mixes one local precondition with service-thrown reasons it flags each service reason as dead. Add `thrownBy: 'service'` to those entries:
```ts
errors: [
{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError,
when: 'Input is empty.',
recovery: 'Provide a non-empty expression to evaluate.',
thrownBy: 'service' },
]
```
The field is lint-only metadata — nothing at runtime reads it, so the entry is typed, advertised, and thrown exactly as an unmarked one (its `recovery` filled like any other), and its reason stays in the `ctx.fail` / `ctx.recoveryFor` union. It suppresses the one rule that cannot see below the handler, and only for the entries it marks; the handler's own reasons keep being checked.
---
## 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.
A best-effort call that catches and degrades must still rethrow on `ctx.signal?.aborted`: `catch (err) { if (ctx.signal?.aborted) throw err; return degraded(); }`. One example is an enrichment lookup whose failure should return the primary result with a notice. The factory maps a cancelled handler to `RequestCancelled` only when the handler throws. A catch-all degrade turns the caller's cancellation into a "successful" response and logs a false failure warning.
---
## 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 }`. Each one's stack starts at the line that called it, with the factory's own frame cut.
```ts
throw notFound('Item not found', { itemId: '123' });
throw validationError('Missing required field: name', { field: 'name' });
throw unauthorized('Token expired');
// With cause for error chaining
throw serviceUnavailable('API call failed', { endpoint: 'search' }, { 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) |
View on GitHub