Loading
Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
Recommended by author
## 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
1. **Gather** the tool's name, purpose, and input/output shape from the user's request — ask only if genuinely absent
2. **Determine if long-running** — if the tool involves streaming, polling, or
multi-step async work, it should use `task: true`
3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
4. **Register** the tool in the project's existing `createApp()` tool list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
5. **Run `bun run devcheck`** to verify — if Biome reports formatting issues, run `bun run format` to auto-fix, then re-run devcheck
6. **Smoke-test** with `bun run rebuild && bun run start:stdio` (or `start: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
```typescript
/**
* @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') }];
},
});
```
### Task tool variant
Add `task: true` and use `ctx.progress` for long-running operations:
```typescript
export const [TOOL_EXPORT] = tool('[tool_name]', {
description: '[TOOL_DESCRIPTION]',
task: true,
input: z.object({ /* ... */ }),
output: z.object({ /* ... */ }),
async handler(input, ctx) {
// ctx.progress is guaranteed non-null when task: true — the ! assertion is safe here.
await ctx.progress!.setTotal(totalSteps);
for (const step of steps) {
if (ctx.signal.aborted) break;
await ctx.progress!.update(`Processing: ${step}`);
// ... do work ...
await ctx.progress!.increment();
}
return { /* output */ };
},
});
```
### Registration
```typescript
// 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.
```typescript
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 composes with both standard and task tools, and preserves all original definition fields (handler, schemas, auth scopes, error contracts) — when re-enabled, the tool already conforms to every lint rule.
## 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`, advertises `output.extend(enrichment)` as the tool's `outputSchema`, 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.
```typescript
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:
```typescript
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.
### 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:
```ts
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`:
```ts
// 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.
```typescript
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.
```typescript
// 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:
```typescript
// 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.
```typescript
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.'),
}),
```
— [truncated; see full source: https://github.com/cyanheads/mcp-ts-core]Running prompts needs a free account.
Sign in and we'll stream the response from Claude Opus 4.7 right here — no config needed for the platform models.
Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
## 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
1. **Gather** the tool's name, purpose, and input/output shape from the user's request — ask only if genuinely absent
2. **Determine if long-running** — if the tool involves streaming, polling, or
multi-step async work, it should use `task: true`
3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
4. **Register** the tool in the project's existing `createApp()` tool list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
5. **Run `bun run devcheck`** to verify — if Biome reports formatting issues, run `bun run format` to auto-fix, then re-run devcheck
6. **Smoke-test** with `bun run rebuild && bun run start:stdio` (or `start: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
```typescript
/**
* @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') }];
},
});
```
### Task tool variant
Add `task: true` and use `ctx.progress` for long-running operations:
```typescript
export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
description: '{{TOOL_DESCRIPTION}}',
task: true,
input: z.object({ /* ... */ }),
output: z.object({ /* ... */ }),
async handler(input, ctx) {
// ctx.progress is guaranteed non-null when task: true — the ! assertion is safe here.
await ctx.progress!.setTotal(totalSteps);
for (const step of steps) {
if (ctx.signal.aborted) break;
await ctx.progress!.update(`Processing: ${step}`);
// ... do work ...
await ctx.progress!.increment();
}
return { /* output */ };
},
});
```
### Registration
```typescript
// 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.
```typescript
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 composes with both standard and task tools, and preserves all original definition fields (handler, schemas, auth scopes, error contracts) — when re-enabled, the tool already conforms to every lint rule.
## 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`, advertises `output.extend(enrichment)` as the tool's `outputSchema`, 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.
```typescript
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:
```typescript
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.
### 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:
```ts
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`:
```ts
// 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.
```typescript
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.
```typescript
// 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:
```typescript
// 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.
```typescript
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.'),
}),
```
— [truncated; see full source: https://github.com/cyanheads/mcp-ts-core]{{tool_description}}{{tool_name}}{{tool_export}}{{tool_title}}{{step}}