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 a la instalación

Datos de origen

Repositorio
cyanheads/mcp-ts-core
Última actividad en el origen
14 de septiembre de 2026 a las 02:27
Idioma detectado de SKILL.md
inglés
Estrellas
151
Forks
29

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
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.26","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_eligible`. The server prefix is judged on clarity, not length: the brand name or the plain well-known word for the domain both pass (`pubmed_`, `patents_`); an abbreviation fails only when it reads as something else out of context (`loc_`, `ct_`). A fourth segment is fine when the noun is inherently two words (`openfda_search_device_clearances`). When a name resists the schema — can't pick a verb, noun feels generic, the *verb* wants a second word — 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. Whether the caller can *answer* is a separate question — a 2025-era HTTP client cannot when the server runs `MCP_SESSION_MODE=stateless`, which a server needing that leg declares with `createApp({ sessionMode: { require: 'stateful' } })` rather than leaving to a deployment (`api-context` § `ctx.requestInput`). Treat an unanswered round as terminal, never as consent. ```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): `framework-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` (Server Card) | **No** — the card carries no per-tool entries at all, so a discovery agent reading it cannot see a disabled tool | | `/` (HTML landing page) | **Yes**, in a 4th muted bucket after `read \| write \| destructive` — the only surface where a disabled tool is visible | 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: ```typescript const { enableWrites } = getServerConfig(); // The suggestion is emitted only under the config that registers its target. 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: ```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:
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub