- name
- add-tool
- description
- 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.
- metadata
- {"author":"cyanheads","version":"2.20","audience":"external","type":"reference"}
## 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 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`, see `api-context`)
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') }];
},
});
```
### 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.
```typescript
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
```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 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:
```text
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 nested `z.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, and `tool()` 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: false` lands.
### 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:
```ts
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),
});
Ver no GitHub