| 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.16","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 long-running — if the tool involves streaming, polling, or
multi-step async work, it should use
task: true
- 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') }];
},
});
Task tool variant
Add task: true and use ctx.progress for long-running operations:
export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
description: '{{TOOL_DESCRIPTION}}',
task: true,
input: z.object({ }),
output: z.object({ }),
async handler(input, ctx) {
await ctx.progress!.setTotal(totalSteps);
for (const step of steps) {
if (ctx.signal.aborted) break;
await ctx.progress!.update(`Processing: ${step}`);
await ctx.progress!.increment();
}
return { };
},
});
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 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.
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);
ctx.enrich.total(res.total);
if (res.items.length === 0) ctx.enrich.notice(`No matches for "${input.query}".`);
return { items: res.items };
},
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' },
appliedFilters: { render: (f) => `### Filters\n- Range: ${f.dateRange}` },
},
structuredContent always keeps the full structured value; enrichmentTrailer only controls the human-facing content[] line.