| 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.22","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. 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) {
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.
Audit what still names the tool
Gating a tool removes it from tools/list, but nothing rewrites the rest of the server. Every reference that survives points a client at a name it cannot call. Sweep for the tool's name across three surfaces and fix what the gate makes wrong:
| Surface | What the gate requires |
|---|
Static prose — server instructions, tool descriptions, field .describe() text | Do not describe a disabled tool as currently callable. |
Recovery text — errors[].recovery, ctx.fail hints, ctx.enrich notices, service summaries | Offer an available next step, or say the capability is unavailable in this deployment. |
Structured suggestions — nextToolSuggestions, or any { toolName, args } entry a client executes | Emit a suggestion only when its target is enabled under the same configuration. |
A suggestion is executable; prose is not. When no callable alternative exists, prose may still explain the limitation — but the executable entry goes:
const { enableWrites } = getServerConfig();
const nextToolSuggestions = enableWrites
? [{ toolName: 'brapi_submit_observations', args: { studyDbId } }]
: [];
return {
observations,
nextToolSuggestions,
...(enableWrites
? {}
: { notice: 'Submitting observations is turned off in this deployment.' }),
};
The same audit applies to a tool's own errors[].recovery: a hint naming a tool that this deployment gates off sends the agent to a dead end at exactly the moment it is recovering from a failure.
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.