Context
Tools use the tool() builder from @cyanheads/mcp-ts-core. Each tool lives in src/mcp-server/tools/definitions/ with a .tool.ts suffix. The standard registration pattern uses a definitions/index.ts barrel that collects all tools into an allToolDefinitions array for createApp(). Fresh scaffolds from init start with direct imports in src/index.ts — the barrel is introduced as definitions grow. Match the pattern already used by the project you're editing.
Steps
- Gather the tool's name, purpose, and input/output shape from the user's request — ask only if genuinely absent
- Determine if it needs input the caller may not supply — a confirmation, a choice, the client's roots — which makes it a multi-round-trip handler (
ctx.requestInput/ctx.inputs, seeapi-context) - Create the file at
src/mcp-server/tools/definitions/{{tool-name}}.tool.ts - Register the tool in the project's existing
createApp()tool list (directly insrc/index.tsfor fresh scaffolds, or via a barrel if the repo already has one) - Run
bun run devcheckto verify — if Biome reports formatting issues, runbun run formatto auto-fix, then re-run devcheck - Smoke-test with
bun run rebuild && bun run start:stdio(orstart:http)
Naming
Tools use lowercase snake_case with a canonical server/domain prefix: {server}_{verb}_{noun} — 3 words.
Examples: pubmed_search_articles, pubmed_fetch_fulltext, clinicaltrials_find_studies.
The server prefix uses the canonical platform/brand name, not an abbreviation (patentsview_ not patents_, clinicaltrials_ not ct_). When a name resists the schema — can't pick a verb, noun feels generic, wants 4+ segments — that's usually a signal the scope is fuzzy; split the tool, rename, or reconsider.
For shape selection (Workflow or Instruction variants — standard single-action tools are the default), see the design-mcp-server skill's Tool shapes section.
Template
/**
* @fileoverview {{TOOL_DESCRIPTION}}
* @module mcp-server/tools/definitions/{{TOOL_NAME}}
*/
import { tool, z } from '@cyanheads/mcp-ts-core';
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
title: '{{TOOL_TITLE}}',
// Single cohesive paragraph — pack operational guidance into prose sentences,
// not bullet lists or blank-line-separated sections. Descriptions render inline.
description: '{{TOOL_DESCRIPTION}}',
annotations: { readOnlyHint: true },
input: z.object({
// All fields need .describe(). Only JSON-Schema-serializable Zod types allowed.
}),
output: z.object({
// All fields need .describe(). Only JSON-Schema-serializable Zod types allowed.
}),
// Agent-facing context on the success path — empty-result notices, the query as
// the server parsed it, pagination totals. The counterpart to errors[]: merged
// into structuredContent AND mirrored into content[] automatically (no format()
// entry needed, never touched by format-parity). Populate via ctx.enrich(...) in
// the handler or service layer. Keys must be disjoint from output. Delete if unused.
enrichment: {
effectiveQuery: z.string().describe('The query as the server parsed it.'),
totalCount: z.number().describe('Total matches before any limit was applied.'),
},
// auth: ['tool:{{tool_name}}:read'],
// Each entry declares a domain-specific failure mode and types
// `ctx.fail(reason, …)` against the declared union. Baseline codes
// (InternalError, ServiceUnavailable, Timeout, ValidationError,
// SerializationError) bubble freely — only declare domain-specific reasons.
// Delete this block if no domain failures apply.
//
// Keep contracts inline on this tool, even when other tools have similar
// entries. The contract is part of the tool's documented public surface —
// don't extract a shared `errors[]` constant; per-tool repetition is the
// intended cost of self-contained tool defs.
//
// `recovery` is required (≥ 5 words) — it's the agent's next move when this
// failure fires. Forcing function for thoughtful guidance: placeholders like
// "Try again." get flagged by the linter. The contract `recovery` is the
// single source of truth for what flows to the wire — opt in at the throw
// site by spreading `ctx.recoveryFor('reason')` into the `data` arg.
errors: [
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
when: 'Local queue at capacity.', retryable: true,
recovery: 'Wait a few seconds before retrying or reduce batch size.' },
],
async handler(input, ctx) {
ctx.log.info('Processing', { /* relevant input fields */ });
// Pure logic — throw on failure, no try/catch.
// With an `errors[]` contract: `throw ctx.fail('reason_id', message?, data?)`.
// Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`.
const items = await search(input);
if (queue.full()) {
// Static recovery — resolve from the contract via ctx.recoveryFor('reason').
// Single source of truth: the string lives in errors[] above; this spread
// pulls it onto the wire so format()-only clients see the recovery hint.
throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
}
// Surface what the agent reasons with — echoed query, true total — on BOTH
// client surfaces, with no format() plumbing. An empty result is a notice,
// not a throw: reserve ctx.fail for genuine failures (queue full, upstream down).
ctx.enrich.echo(input.query);
ctx.enrich.total(items.length);
if (items.length === 0) {
ctx.enrich.notice(`No items matched "${input.query}". Try broader terms or check the spelling.`);
}
return { items };
},
// format() populates MCP content[] — the markdown twin of structuredContent.
// Different clients read different surfaces (Claude Code → structuredContent,
// Claude Desktop → content[]), so both must carry the same data.
// Enforced at lint time: every field in `output` must appear in the rendered text.
format: (result) => {
const lines: string[] = [];
// Render each item with all relevant fields — not just a count or title.
// A thin one-liner (e.g., "Found 5 items") leaves the model blind to the data.
for (const item of result.items) {
lines.push(`## ${item.name}`);
lines.push(`**ID:** ${item.id} | **Status:** ${item.status}`);
if (item.description) lines.push(item.description);
}
return [{ type: 'text', text: lines.join('\n') }];
},
});
Multi-round-trip variant
A handler that needs something the caller didn't supply returns ctx.requestInput(...) and is re-entered with the answers on ctx.inputs. There is no mid-handler await for user input, and no capability check — the surface is always present, on every transport and both protocol eras. Whether the caller can answer is a separate question — a 2025-era HTTP client cannot when the server runs MCP_SESSION_MODE=stateless (api-context § ctx.requestInput). Treat an unanswered round as terminal, never as consent.
import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
import { validationError } from '@cyanheads/mcp-ts-core/errors';
const Confirm = z.object({ confirm: z.boolean().describe('Whether to proceed.') });
export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
description: '{{TOOL_DESCRIPTION}}',
input: z.object({ /* ... */ }),
output: z.object({ /* ... */ }),
annotations: { destructiveHint: true },
handler(input, ctx) {
// Read what a prior round collected before asking for anything.
const answer = ctx.inputs.accepted('confirm', Confirm);
if (!answer) {
// A declined or cancelled prompt is a dead end — don't re-ask it.
const view = ctx.inputs.view('confirm');
if (view.kind === 'elicit' && view.action !== 'accept') {
throw validationError(`User ${view.action} the confirmation.`);
}
return ctx.requestInput({
inputRequests: {
confirm: inputRequired.elicit({
message: `Proceed with ${input.target}?`,
requestedSchema: Confirm,
}),
},
});
}
// `answer` is narrowed here.
return { /* output */ };
},
});
Write it as return ctx.requestInput(...) — the never return type makes it valid in return position for any output, and it is what lets TypeScript narrow the line below. Full reference (inputRequired.elicitUrl / .createMessage / .listRoots, requestState, decline handling): skills/api-context.
Registration
// src/index.ts (fresh scaffold default)
import { createApp } from '@cyanheads/mcp-ts-core';
import { existingTool } from './mcp-server/tools/definitions/existing-tool.tool.js';
import { {{TOOL_EXPORT}} } from './mcp-server/tools/definitions/{{tool-name}}.tool.js';
await createApp({
tools: [existingTool, {{TOOL_EXPORT}}],
resources: [/* existing resources */],
prompts: [/* existing prompts */],
});
If the repo already uses src/mcp-server/tools/definitions/index.ts, update that barrel instead of switching patterns midstream.
Feature-flagged tools (disabledTool wrapper)
When a tool is gated behind config (e.g., BRAPI_ENABLE_WRITES, FOO_PRO_FEATURES), the gate has two failure modes when wired naively. Excluding the tool from the array hides it from MCP registration and from the HTTP landing page — operators see a smaller catalog than the README documents and have no in-page hint that the tool exists at all. Always registering it lets clients call the tool and forces handler-side forbidden throws, which keeps the dangerous surface in the LLM's reach.
disabledTool() resolves this: the wrapped tool is present in the manifest and rendered on the landing page (muted card, with a reason and an optional hint for how to enable it), but skipped during MCP server registration so clients cannot call it.
import { disabledTool, tool, z } from '@cyanheads/mcp-ts-core';
import { getServerConfig } from '@/config/server-config.js';
const submitObservationsDef = tool('brapi_submit_observations', {
description: 'Submit observation records (POST/PUT) with elicit gate.',
annotations: { readOnlyHint: false, destructiveHint: false },
input: z.object({ /* … */ }),
output: z.object({ /* … */ }),
async handler(input, ctx) { /* … */ },
});
export const submitObservations = getServerConfig().enableWrites
? submitObservationsDef
: disabledTool(submitObservationsDef, {
reason: 'Writes are turned off in this deployment.',
hint: 'BRAPI_ENABLE_WRITES=true',
});
DisabledMetadata shape: { reason: string; hint?: string; since?: string }. The reason renders as a sentence on the disabled card; hint (when present) renders as a code-styled block — use whatever the gate is (env var line, config key, doc reference). since annotates the card with a small "since vX" tag — useful when phasing a tool out behind a flag before removal.
Three tool listings to keep straight:
| Surface | Disabled tools? |
|---|---|
tools/list (MCP protocol — what clients call) |
No — disabled tools are skipped at registration |
/.well-known/mcp.json definitions.tools (Server Card) |
Yes, with disabled field — discovery agents see them as present-but-uncallable |
/ (HTML landing page) |
Yes, in a 4th muted bucket after read | write | destructive |
The wrapper preserves all original definition fields (handler, schemas, auth scopes, error contracts) — when re-enabled, the tool already conforms to every lint rule.
Schemas: what the framework stores vs. what clients see
tool() and the handler factory do not hand your Zod schemas to the SDK verbatim. Two deliberate transforms sit in between.
Input is strict
tool() stores input with .strict() applied, and the advertised inputSchema carries additionalProperties: false to match. An unrecognized argument key is rejected by name before the handler runs:
Input validation error: Invalid arguments for tool <name>: Unrecognized key: "querry"
That arrives as an isError: true result, not a JSON-RPC error, and produces no framework span or log. The alternative — silently stripping the key — turns a caller's typo into a wrong answer they cannot detect: the value vanishes before the handler runs and the call fails downstream pointing at the wrong problem.
Two limits worth knowing when you write a schema:
- Root level only, matching
.strict()itself. A nestedz.object()inside the input still strips unknown keys unless it is strict in its own right — mark the nested option objects you want guarded. - An explicit opening wins. A definition that declared
.passthrough()or.catchall(...)asked for an open object, andtool()leaves it alone. Use that (deliberately) for tools that proxy arbitrary upstream query parameters. - A union root is strictened per variant. See below — the branch is where the properties live, so that is where
additionalProperties: falselands.
Multi-mode tools take a discriminated-union input
When a tool has genuinely exclusive argument sets — look up by ID or search by name, never both — declare the union directly instead of making every field optional and checking the combination by hand:
const lookup = tool('lookup', {
description: 'Looks a record up by exactly one of the supported keys.',
input: z.discriminatedUnion('mode', [
z.object({
mode: z.literal('byId').describe('Look up by exact ID.'),
id: z.string().describe('Record ID.'),
}),
z.object({
mode: z.literal('byName').describe('Search by name.'),
name: z.string().describe('Name fragment.'),
fuzzy: z.boolean().default(false).describe('Whether to match loosely.'),
}),
]),
output: z.object({ /* … a flat object; see below */ }),
handler: (input) =>
input.mode === 'byId' ? byId(input.id) : byName(input.name, input.fuzzy),
});
The handler dispatches on the discriminator and TypeScript narrows input to that branch — input.id exists only under 'byId', and reaching for input.name there is a compile error.
What reaches the wire is {"type": "object", "oneOf": [<branch>, …]}: branches intact, each with its own required list and a const-tagged discriminator, additionalProperties: false on every one. Identical bytes on a 2025-11-25 and a 2026-07-28 connection — the legacy projection inspects outputSchema alone and never rewrites an input root.
Three constraints:
- The union must be discriminated. A bare
z.union(...)is rejected: with no literal-tagged key the model has nothing to choose a branch by, and every variant'srequiredwould read as applying at once. outputstays a flatz.object— see the widening section below for why a non-object output root breaks the success path. When the result shape varies by mode, use akinddiscriminator with presence-based optional fields and render each arm on field presence informat().- Portability is unmeasured at the parameter root.
schema-root-oneof-portability(strict mode only) says so; for Anthropic clients the union is the better shape, and flattening is the escape hatch if you target the widest vendor matrix. - A union root rules out
headerParam. See below — the branches sit underoneOf, which the reachability rule excludes.
headerParam mirrors an argument into a request header
Protocol revision 2026-07-28 lets a tool designate an input property with x-mcp-header, so its value also rides an Mcp-Param-<Name> request header. A proxy, gateway, or router can then read it without parsing the JSON-RPC body:
import { headerParam, tool, z } from '@cyanheads/mcp-ts-core';
input: z.object({
query: z.string().describe('Search query.'),
routing: z.object({
region: headerParam(z.string(), 'Region').describe('Deployment region.'),
shard: headerParam(z.int(), 'Shard-Id').describe('Shard the record lives on.'),
}).describe('Where to route the lookup.'),
}),
The emitted property carries "x-mcp-header": "Region" and nothing else about the field changes — description, type, validation, and requiredness are untouched. Order does not matter: headerParam(z.string(), 'Region').describe('…') and headerParam(z.string().describe('…'), 'Region') are the same schema.
It mirrors, it does not relocate. When the body carries a value for a designated property, the matching Mcp-Param-<Name> header MUST be present and decode to an equal value; the SDK cross-checks the pair before dispatch and rejects a disagreement with -32020 (HeaderMismatch, HTTP 400). Absent or null in the body means no header is expected. Your handler still reads the argument from input — there is nothing new to do in the handler body.
Where a designation is legal. The property must be primitive-typed (string, integer, number, boolean) and statically reachable through a chain of properties keys. Top-level and nested z.object() fields qualify. These do not:
| Placement | Why |
|---|---|
An array element (z.array(z.object({ … }))) |
Lives under items |
A z.record() value |
Lives under additionalProperties |
| Any field of a discriminated-union input root | The root advertises oneOf, so every branch is off the chain — no field of a union-input tool can be designated |
A schema reused under .meta({ id }) |
Hoisted into $defs and reached by $ref |
Header names must be non-empty RFC 9110 tokens (no spaces, control characters, or HTTP delimiters) and case-insensitively unique across the whole input schema.
Violations fail at definition time. tool() throws on import, naming the field path and the reason. That is deliberate: the SDK only console.warns and registers the tool anyway, leaving conforming Streamable HTTP clients to drop it from tools/list — a tool that silently disappears with nothing reporting the gap. The linter reports the same verdict as header-param-designation for definitions assembled without the builder.
The advertised outputSchema is widened
The framework parses a successful result against the strict effective schema — output, extended with the enrichment block when one is declared — so a required field the handler never populated still fails loudly. What it advertises in tools/list is a widened projection of that schema: every success field optional, plus a declared error property describing the failure envelope.
The reason is client-side validation. A failing tool returns structuredContent: { error: … }, which can never satisfy a success-only schema; clients whose SDK validates structuredContent without first checking isError reject that envelope with -32602 before the error ever reaches the agent. Widening the advertised schema is the only fix a server can ship, because the validator runs in the caller.
The root stays type: 'object' (a discriminated union would emit anyOf with no type, which the 2025-era legacy projection rewrites — breaking the success path to fix the error path). The required list that the object form drops is recovered by an anyOf refinement in schema metadata: a result must satisfy either the success branch (success fields present, no error) or the failure branch (error present).
Practical consequence: do not read the advertised schema as the contract your handler must satisfy. output is still the contract. The widened form is emission only.
data.reason inside that envelope stays an unconstrained string. An errors[] contract covers what the handler throws, but a service it calls can raise its own reason (the SQL gate's denied_function, a parser's yaml_parse_failed), and that reaches the wire verbatim — an enum of the declared reasons would reject precisely those envelopes, recreating the -32602 the widening exists to prevent. The declared reasons are emitted as examples and spelled out in the description instead.
error is a reserved output field name. tool() throws if output or enrichment declares one: on the wire a failure is structuredContent.error, so a success payload using the same key cannot be told apart from a failure. Rename it (errorText, failureDetail).
Tool Response Design
Tool responses are the LLM's only window into what happened. Every response should leave the agent informed about outcome, current state, and what to do next. This applies to success, partial success, empty results, and errors alike.
Agent-facing context belongs in enrichment
Empty-result notices, the query/filter as the server parsed it, pagination totals — the context an agent reasons with, as opposed to the domain payload itself — must reach both client surfaces: structuredContent (from output) and content[] (from format()). Hand-authored into format() text alone, this context reaches content[] but is invisible to structuredContent-only clients (Claude Code, MCP-SDK API callers).
Declare it as an enrichment block — the success-path counterpart to errors[] — and populate it via ctx.enrich(...) (or the kind-tagged helpers ctx.enrich.notice() / .total() / .echo()). The framework merges enrichment into structuredContent, folds the block into the tool's advertised outputSchema (see Schemas), and mirrors it into a content[] trailer — both surfaces, no format() entry, never touched by format-parity. ctx.enrich lives on the base Context (like ctx.log), so the service layer can populate it too.
enrichment: {
effectiveQuery: z.string().describe('The query as the server parsed it.'),
totalCount: z.number().describe('Total matches before the limit.'),
notice: z.string().optional().describe('Guidance when nothing matched.'),
},
async handler(input, ctx) {
const res = await search(input.query, input.limit);
ctx.enrich.echo(res.parsed); // → structuredContent.effectiveQuery + "Query: …" trailer
ctx.enrich.total(res.total); // → structuredContent.totalCount + "N total" trailer
if (res.items.length === 0) ctx.enrich.notice(`No matches for "${input.query}".`);
return { items: res.items }; // enrichment never rides in the domain return
},
A required enrichment field the handler never populates fails the effective-output parse — surfacing the bug rather than dropping it silently. Enrichment keys must be disjoint from output keys (lint-enforced). The sections below are applications of this rule.
Trailer rendering is a per-field call. Each field's content[] trailer line resolves as: its kind-tag if set (notice/total/echo/delta), else the definition's per-field enrichmentTrailer.render/label, else the generic **key:** value (objects/arrays JSON.stringify'd). A structured (object/array) field with no render ships as a one-line JSON blob — the enrichment-trailer-render lint rule errors on that. Give it a renderer, or a label to relabel a scalar key:
enrichment: {
totalFound: z.number().describe('Matches before the page limit.'),
appliedFilters: z.object({ /* … */ }).describe('Filters the server applied.'),
},
enrichmentTrailer: {
totalFound: { label: 'Total Found' }, // → "**Total Found:** 2990"
appliedFilters: { render: (f) => `### Filters\n- Range: ${f.dateRange}` }, // markdown, not JSON
},
structuredContent always keeps the full structured value; enrichmentTrailer only controls the human-facing content[] line.
Image / audio output belongs in ctx.content
When a tool produces image or audio bytes for the calling model to see or hear — a rendered chart, a generated frame, synthesized speech — emit them via ctx.content, not an output field. ctx.content.image(data, mimeType) / .audio(data, mimeType) prepend a content block to content[] after format() runs and never write to structuredContent, so the base64 is carried once instead of duplicating into the typed output. Like ctx.enrich, it lives on the base Context and is callable from the service layer.
async handler(input, ctx) {
const png = await render(input.spec); // base64 PNG
ctx.content.image(png, 'image/png'); // → content[] block, not structuredContent
return { width: input.spec.w, height: input.spec.h }; // typed result stays small
},
The alternative — declaring previewData: z.string() in output and emitting the block from format() — ships the bytes twice (once in structuredContent, once in the block). Reserve output for data the agent reasons over; route raw media through ctx.content. Test with getContentBlocks(ctx). Full reference: skills/api-context § ctx.content.
Capped lists must disclose truncation
When a tool accepts a cap-like input (limit, per_page, page_size, max_results, max_items) and returns an array, disclose when the cap was hit — the agent otherwise treats a partial set as complete.
The one-liner: ctx.enrich.truncated({ shown, cap }). Declare the fields in the enrichment block:
enrichment: {
truncated: z.boolean().describe('True when the list was capped at the limit.'),
shown: z.number().describe('Number of items returned.'),
cap: z.number().describe('The limit that was applied.'),
},
async handler(input, ctx) {
const items = await fetchItems(input.limit);
if (items.length >= input.limit) {
ctx.enrich.truncated({ shown: items.length, cap: input.limit });
}
return { items };
},
Alternatively, if the upstream total is known, ctx.enrich.total(n) (writes totalCount) also satisfies the lint rule.
Threshold bound — when the upstream total is unknowable but the list is sorted by the cap key, the smallest shown value is a rigorous upper bound on all omitted items (Fagin Threshold Algorithm). Pass it as ceiling:
// items is sorted descending by count; anything hidden has count ≤ items.at(-1).count
ctx.enrich.truncated({
shown: items.length,
cap: input.limit,
ceiling: items.at(-1)?.count,
guidance: 'Narrow with filters or raise per_page (max 200).',
});
Declare truncationCeiling: z.number().optional() in the enrichment block to surface it. The capped-list-no-truncation lint rule warns when this disclosure is absent — see api-linter.
Communicate filtering and exclusions
If the tool omitted, truncated, or filtered anything, say what and how to get it back. Silent omission is invisible to the agent — it can't act on what it doesn't know about.
output: z.object({
items: z.array(ItemSchema).describe('Matching items (up to limit).'),
totalCount: z.number().describe('Total matches before pagination.'),
excludedCategories: z.array(z.string()).optional()
.describe('Categories filtered out by default. Use includeCategories to override.'),
}),
Batch input and partial success
When a tool accepts an array of items, some may succeed while others fail. Report both — don't silently return successes and swallow failures.
// Output schema — design for per-item results
output: z.object({
succeeded: z.array(ItemResultSchema).describe('Items that completed successfully.'),
failed: z.array(z.object({
id: z.string().describe('Item ID that failed.'),
error: z.string().describe('What went wrong and how to resolve it.'),
})).describe('Items that failed with per-item error details.'),
}),
// Handler — collect results, don't throw on individual failures
async handler(input, ctx) {
const succeeded: ItemResult[] = [];
const failed: { id: string; error: string }[] = [];
for (const id of input.ids) {
try {
succeeded.push(await processItem(id));
} catch (err) {
failed.push({ id, error: err instanceof Error ? err.message : String(err) });
}
}
return { succeeded, failed };
},
Note on the try/catch: this is the deliberate exception to the "logic throws, framework catches" rule. Per-item isolation is the whole point of partial-success batch tools — one failed item must not abort the batch, and the framework's partial-success telemetry (below) depends on seeing a populated failed array. Don't remove it to conform to the handler-level rule.
Single-item tools don't need this — they either succeed or throw. The partial success question only arises with array inputs.
Telemetry: The framework automatically detects this pattern — when a handler result contains a non-empty failed array, the span gets mcp.tool.partial_success, mcp.tool.batch.succeeded_count, and mcp.tool.batch.failed_count attributes. No manual instrumentation needed.
Empty results need context
An empty array with no explanation is a dead end. Echo back the criteria that produced zero results and suggest how to broaden. This is the canonical enrichment case — a notice is agent-facing context, not domain payload, and an empty result is a notice, not a throw:
// 1. Declare the notice as enrichment — reaches structuredContent AND content[],
// no output field, no format() entry, no format-parity concern.
enrichment: {
notice: z.string().optional()
.describe('Recovery hint when results are empty — echoes filters and suggests how to broaden.'),
},
// 2. Handler — populate via ctx.enrich.notice() when the result is empty.
async handler(input, ctx) {
const results = await search(input);
if (results.length === 0) {
ctx.enrich.notice(
`No items matched status="${input.status}" in project "${input.project}". `
+ `Try a broader status filter or verify the project name.`,
);
}
return { items: results, totalCount: results.length };
},
The notice lands in structuredContent.notice and renders as a content[] blockquote automatically — both surfaces, zero format() plumbing.
Mutator response design
Mutators (write/update/delete/append/patch verbs, or destructiveHint: true) surface raw pre- and post-mutation observable state — not a synthetic verdict. The server can detect anomalies but can't classify them as problems; only the agent knows whether file shrunk is intentional truncation or a bug.
output: z.object({
path: z.string().describe('Resolved target path.'),
created: z.boolean().describe('True when the operation created a new target.'),
previousSizeInBytes: z.number().describe('Byte size before the mutation. Zero when created is true.'),
currentSizeInBytes: z.number().describe('Byte size after the mutation. Equals previous when no-op.'),
}),
The agent reads created: true, previousSizeInBytes: 0, currentSizeInBytes: 68 and knows: brand new target, the full file content is the body. If that matches intent, fine; if not (typo path, uninitialized periodic note), the agent self-corrects without re-fetching. Anti-pattern: server-side >= integrity throws on mutators — the server can't distinguish intentional shrink from bug, so it throws on every shrink, including the deliberate ones.
When the before/after is agent-facing context rather than primary payload, the enrichment-native form is ctx.enrich.delta({ field, before, after }) — it writes { before, after } to structuredContent and renders **field:** before → after in the content[] trailer. Declare the field in the enrichment block as z.object({ before, after }); the linter recognizes the shape, so it needs no custom enrichmentTrailer.render. Same stance — surface raw state, never a verdict:
enrichment: {
sizeInBytes: z.object({
before: z.number().describe('Byte size before the mutation.'),
after: z.number().describe('Byte size after the mutation.'),
}).describe('Raw size before/after — the agent judges whether a shrink was intended.'),
},
// handler:
ctx.enrich.delta({ field: 'sizeInBytes', before: prev, after: next });
Sparse upstream data must stay honest
When tool output comes from a third-party API, don't overstate certainty. Upstream systems often omit fields entirely; the tool schema and format() should preserve that uncertainty instead of collapsing it into fake false, 0, or empty-string facts.
Guidance:
- Use optional output fields when the upstream source is sparse.
- Render unknown values explicitly (
Not available,Unknown) instead of inventing a concrete value. - Only render booleans, badges, counts, and summary facts when they are actually known.
output: z.object({
repos: z.array(z.object({
id: z.string().describe('Repository ID.'),
name: z.string().describe('Repository name.'),
archived: z.boolean().optional()
.describe('Archived status when provided by the upstream API. Omitted when unknown.'),
stars: z.number().optional()
.describe('Star count when provided by the upstream API. Omitted when unknown.'),
})).describe('Repositories returned by the search.'),
}),
format: (result) => [{
type: 'text',
text: result.repos.map((repo) => [
`## ${repo.name}`,
`**ID:** ${repo.id}`,
typeof repo.archived === 'boolean'
? `**Archived:** ${repo.archived ? 'Yes' : 'No'}`
: '**Archived:** Not available',
repo.stars != null
? `**Stars:** ${repo.stars}`
: '**Stars:** Not available',
].join('\n')).join('\n\n'),
}],
Error classification and messaging
Recommended: declare an errors[] contract. A typed contract surfaces in tools/list and gives the handler a typed ctx.fail(reason, …) keyed by the declared reason union — TypeScript catches ctx.fail('typo') at compile time, data.reason is auto-populated and tamper-proof, and the linter enforces conformance against the handler body.
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
export const fetchArticles = tool('fetch_articles', {
description: 'Fetch articles by PMID.',
errors: [
{ reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
when: 'None of the requested PMIDs returned data.',
recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
when: 'Local request queue at capacity.', retryable: true,
recovery: 'Wait 30 seconds and retry, or reduce batch size.' },
],
input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
async handler(input, ctx) {
// Static recovery — ctx.recoveryFor pulls the contract recovery onto the wire.
// The contract is the single source of truth; this spread surfaces it on the
// wire so format()-only clients see the hint mirrored into content[] text.
if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
const articles = await fetch(input.pmids);
if (articles.length === 0) {
// Dynamic recovery — interpolate runtime context, override the contract default.
throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, {
pmids: input.pmids,
recovery: { hint: `Use pubmed_search_articles to discover valid PMIDs.` },
});
}
return { articles };
},
});
ctx.recoveryFor(reason) resolves the contract's recovery string into the wire shape { recovery: { hint } } — safe to spread into data so format()-only clients see the same recovery hint that structuredContent clients read. Always available on Context (no-op {} when no contract), strictly typed on HandlerContext<R> against the declared reasons. Use it for static recovery; pass { recovery: { hint: \…${dynamic}…` } }` directly when you need runtime context. The contract is the single source of truth — write the recovery once, lint validates it ≥5 words, the resolver carries it to every throw site.
Baseline codes (InternalError, ServiceUnavailable, Timeout, ValidationError, SerializationError) bubble freely and don't need declaring. Wire-level behavior is identical when the contract is omitted, but you lose the type-checked ctx.fail, the tools/list advertisement, and conformance lint coverage — declare a contract whenever the tool has a domain-specific failure mode.
ctx.fail accepts an optional 4th options argument for ES2022 cause chaining: throw ctx.fail('upstream_error', 'Upstream returned 500', { url }, { cause: e }).
Service-layer throws
API-wrapping tools usually delegate to a service: await ncbi.fetch(input, ctx). The throw lives in the service, not the handler. Services accept ctx (the unified Context) so they can call ctx.log, ctx.recoveryFor, etc. The handler doesn't catch — it just bubbles, and the framework's auto-classifier preserves data on the wire.
The contract entry on the tool and the data: { reason } on the service throw need to use the same reason string so the two sides line up. ctx.recoveryFor('reason') resolves the contract recovery from the calling tool's errors[] — same single-source-of-truth pattern that works in handlers.
// service — receives ctx; passes data.reason and spreads ctx.recoveryFor
import type { Context } from '@cyanheads/mcp-ts-core';
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
export class NcbiService {
async fetch(pmids: string[], ctx: Context) {
const response = await fetchWithRetry(...);
if (!response.ok) {
throw serviceUnavailable(`NCBI returned HTTP ${response.status}`, {
reason: 'ncbi_unreachable',
status: response.status,
...ctx.recoveryFor('ncbi_unreachable'), // resolves from caller's contract
});
}
return response.json();
}
}
// tool — declares the matching contract entry, calls the service, doesn't catch
export const fetchArticles = tool('fetch_articles', {
errors: [
{ reason: 'ncbi_unreachable', code: JsonRpcErrorCode.ServiceUnavailable,
when: 'NCBI E-utilities is unreachable.', retryable: true,
recovery: 'NCBI is degraded; retry in a few minutes.' },
],
async handler(input, ctx) {
return { articles: await ncbi.fetch(input.pmids, ctx) }; // throws bubble unchanged
},
});
ctx.recoveryFor returns {} when the calling tool has no contract or the reason isn't declared, so the spread is always safe — services don't have to know which tool called them.
See add-service for the full pattern.
Ad-hoc factory throws (fallback)
When no contract entry fits — prototype code, one-off throws, or service-layer fallbacks — use error factories or plain throw new Error(). The framework auto-classifies plain Error from message patterns as a last resort.
// Client input error — agent can fix and retry
import { validationError, notFound } from '@cyanheads/mcp-ts-core/errors';
throw validationError(`Invalid date format: "${input.date}". Expected YYYY-MM-DD.`);
// Not found — valid input but entity doesn't exist
throw notFound(
`Project "${input.slug}" not found. Check the slug or use project_list to see available projects.`
);
// Upstream API — transient, may resolve on retry
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
throw serviceUnavailable(`arXiv API returned HTTP ${status}. Retry in a few seconds.`);
// Recovery hint via the canonical `data.recovery.hint` shape — the framework
// auto-mirrors it into the content[] text as `Recovery: <hint>`, so format()-only
// clients (Claude Desktop) see the same guidance that structuredContent clients
// (Claude Code) read from `error.data.recovery.hint`. Other `data` keys reach
// structur
…(truncated)