Skip to main content

add-tool

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.

Ir para a instalação

Informações da origem

Repositório
cyanheads/toolkit-mcp-server
Última atividade na origem
22 de agosto de 2026 às 19:23
Idioma detectado do SKILL.md
inglês
Estrelas
18
Forks
9

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
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
Este SKILL.md e muito grande, entao o SkillsMP mostra aqui apenas a primeira secao. Ver no GitHub