Skip to main content

api-errors

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.

Source facts

Repository
cyanheads/seerr-mcp-server
Last source activity
October 7, 2026 at 13:02
Detected SKILL.md language
English
Stars
2
Forks
1

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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.21","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 keep at most their first 1,024 characters and every array its first 10 entries, with the uncut length or count beside each cut (`originalMessageLength`, `<field>Length`, `<field>Count`, `<field>Lengths`). The wire envelope and `mcp.tool.rejections` are unchanged — the `-32602` result still carries every key and issue whole — and a schema that wrongly rejects valid calls still shows per tool on `mcp.tool.rejections`. **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) | | `conflict(msg, data?, options?)` | Conflict (-32002) |
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub