| 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
- 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, see api-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 in src/index.ts for fresh scaffolds, or via a barrel if the repo already has one)
- Run
bun run devcheck to verify — if Biome reports formatting issues, run bun run format to auto-fix, then re-run devcheck
- 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
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}}',
description: '{{TOOL_DESCRIPTION}}',
annotations: { readOnlyHint: true },
input: z.object({
}),
output: z.object({
}),
enrichment: {
effectiveQuery: z.string().describe('The query as the server parsed it.'),
totalCount: z.number().describe('Total matches before any limit was applied.'),
},
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', { });
const items = await search(input);
if (queue.full()) {
throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
}
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: (result) => {
const lines: string[] = [];
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.
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) {
const answer = ctx.inputs.accepted('confirm', Confirm);
if (!answer) {
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,
}),
},
});
}
return { };
},
});
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
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: [],
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 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:
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({ }),
handler: (input) =>
input.mode === 'byId' ? byId(input.id) : byName(input.name, input.fuzzy),
});