Agent skill

Add Tool

by cyanheads in cyanheads/pubmed-mcp-server

Scaffold a new MCP tool definition. An agent skill from cyanheads/pubmed-mcp-server.

Apache-2.0Auto-check passedAgent Workflows

Install Add Tool

skills CLI
$ npx skills add cyanheads/pubmed-mcp-server --skill add-tool -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install cyanheads/pubmed-mcp-server add-tool --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/cyanheads/pubmed-mcp-server.git skills-src && mkdir -p .claude/skills && cp -r skills-src/framework-skills/add-tool .claude/skills/add-tool && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
add-tool
GitHub stars
156
Token cost
~17k tokens
SKILL.md length
6,920 words
Files
1
Skills in repo
30
Repo updated
First seen
Licence
Apache-2.0

At a glance

Scaffold a new MCP tool definition. An agent skill from cyanheads/pubmed-mcp-server.

  • Works in 6 steps: Gather the tool's name, purpose, and… → Determine if it needs input the caller… → Create the file at… → …
  • The user asks to add a tool
  • SKILL.md covers Context, Steps, Naming and Template, plus 4 more sections
  • Calls bun; needs MCP_REQUEST_STATE_KEY

What it does

Add Tool is an agent skill from cyanheads/pubmed-mcp-server. 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.

Its SKILL.md is about 17k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Agent Workflows, covering MCP servers. It works with Model Context Protocol. The repository describes itself as: Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms via MCP. STDIO or Streamable HTTP. The licence is Apache-2.0.

When your agent uses it

  • The user asks to add a tool
  • Create a new tool
  • Implement a new capability for the server

Example prompts

  • “/add-tool”

Requirements

  • A credential in MCP_REQUEST_STATE_KEY

Workflow steps

6 steps, taken from the first numbered list in SKILL.md.

  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…
  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…
  5. Run bun run devcheck to verify — it applies Biome's formatting fixes as it runs
  6. Smoke-test with bun run rebuild && bun run start:stdio < /dev/null (or start:http) — the Core services constructed log record must list…

What it can do on your machine

Read from SKILL.md and the folder at commit 5a417fb. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • bun

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Links to these hosts (documentation or services it may open):

    • github.com

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names these keys or tokens, usually read from environment variables:

    • MCP_REQUEST_STATE_KEY

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Add Tool loads about 17k tokens when it runs. Until then it costs about 37 tokens; SKILL.md has 6,920 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~37
When it runs · the whole SKILL.md, loaded when a task matches
~17k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from cyanheads/pubmed-mcp-server at commit 5a417fb, republished under its Apache-2.0 licence (© cyanheads). 6,920 words, ~17,351 tokens.

Download SKILL.mdSave it as .claude/skills/add-tool/SKILL.md (or your agent's skills folder).
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
metadata.version
2.33
metadata.audience
external
metadata.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 — it applies Biome's formatting fixes as it runs
  6. Smoke-test with bun run rebuild && bun run start:stdio < /dev/null (or start:http) — the Core services constructed log record must list the new tool in its tools field (the message text shows only counts); if it doesn't, the tool never reached createApp()

Naming

Tools use lowercase snake_case with a canonical server/domain prefix, {server}_{verb}_{noun} by default. Drop the noun only when the verb is a complete action on its own (git_pull, git_status): if {server}_{verb} leaves "…what?" unanswered — search what? connect to what? — the noun is missing. The full rule is the Name row of the design-mcp-server Design table.

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, Instruction, or Reference 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 — the framework sends it
  // with any failure carrying the reason and no hint of its own.
  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 — the string lives in errors[] above, and the framework
      // puts it on both client surfaces as `data.recovery.hint`.
      throw ctx.fail('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 Choice = z.object({ region: z.enum(['us', 'eu']).describe('Region to deploy to.') });

export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
  description: '{{TOOL_DESCRIPTION}}',
  input: z.object({ /* ... */ }),
  output: z.object({ /* ... */ }),

  handler(input, ctx) {
    // A declined or cancelled prompt is a dead end — don't re-ask it.
    const view = ctx.inputs.view('region');
    if (view.kind === 'elicit' && view.action !== 'accept') {
      throw validationError(`User ${view.action} the region prompt.`);
    }
    // Read what a prior round collected before asking for anything.
    const answer = ctx.inputs.accepted('region', Choice);
    if (!answer) {
      return ctx.requestInput({
        inputRequests: {
          region: inputRequired.elicit({ message: 'Which region?', requestedSchema: Choice }),
        },
      });
    }
    // `answer` is narrowed here.
    return { /* output */ };
  },
});

A confirmation before a destructive step is different. ctx.inputs only carries response kinds the client declared, but a client that declared elicitation can still send an "accepted" answer on a call nothing asked, and any requestState replays within its lifetime. A consent gate stores { operation, clientId, subject, target, contentHash } in ctx.state under a random id, sends only that id as requestState, redeems the record before anything else in the handler, and asks again on an unknown, used, or expired id or on any field that differs from this call — the full handler, and the concurrency limit an action that must not repeat has to design around, are in api-context § Consent gates. Pair it with MCP_REQUEST_STATE_KEY so a retry can only carry state this server minted.

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:

SurfaceDisabled 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:

SurfaceWhat the gate requires
Static prose — server instructions, tool descriptions, field .describe() textDo not describe a disabled tool as currently callable.
Recovery text — errors[].recovery, ctx.fail hints, ctx.enrich notices, service summariesOffer an available next step, or say the capability is unavailable in this deployment.
Structured suggestions — nextToolSuggestions, or any { toolName, args } entry a client executesEmit 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', reason: 'Record the observations collected for this study.', 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:

  • 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.

Declare .strict() before .describe() / .meta() on the root. Zod keys both to the schema instance, and .strict() clones without it — so z.object({…}).describe('…') loses the description when tool() strictens, and the advertised inputSchema carries none. z.object({…}).strict().describe('…') keeps it, because an already-strict schema is returned untouched. lint:mcp reports the loss as schema-root-meta-discarded. It bites the root only (and each variant of a union root); field- and nested-level describes are unaffected.

Three things the framework fixes before the schema sees the arguments

Strict input is right for a misspelling the caller can fix, and wrong when the arguments the model wrote were correct and something between the model and the schema was not. An ordered step inside parseToolArguments covers those cases: drop client-added keys → key aliases → parse → on failure, repair and one re-parse, plus one alias-first retry when that still fails and the drop discarded a key (below). All three stages are on by default and none changes what tools/list advertises. A call they rescue carries nothing about them in its response — each change in the attempt your handler receives emits a debug log and a counter (mcp.input.ignored_key, mcp.input.aliased, mcp.input.coerced) instead, so a new client artifact surfaces in telemetry rather than as a failed call. A call they cannot rescue is rejected with the first order's rewrites and underscore-rule drops reported, as data.input and as closing hint sentences (Validated query as targetQuery., Dropped undeclared key _max.), because the issues name only the keys that were validated and the caller could not otherwise tell a bad value from a moved or discarded key.

1. Client-added root keys are dropped. Some clients put their own keys inside arguments: a placeholder when the model sends none, a call description, a call id, or a _meta block that belongs on params. The model never wrote them and cannot remove them, so the retry fails identically. An undeclared root key is dropped when it is underscore-prefixed or on the built-in list (_meta, tool_call_description, toolCallId). Three boundaries: a declared key is never dropped (on a union root, that means every variant's keys); an author-opened root is left alone; and a tool declaring any underscore-prefixed key of its own switches the underscore rule off — otherwise a misspelled _cursor would vanish silently, which is the failure strict input exists to prevent.

2. A key alias reaches the handler under the canonical name. Declare the mappings you know:

ts
export const drugProfile = tool('drug_profile', {
  input: z.object({ drug: z.string().describe('Generic or brand name.') }),
  inputAliases: { drug_name: 'drug', substance: 'drug' },
  // …
});

Alongside those, an undeclared key whose case-folded form (-/_ stripped, lowercased) names exactly one declared key is rewritten too — max_results, Max-Results, and MAXRESULTS all reach a declared maxResults, with nothing declared. Neither half advertises anything: inputSchema is byte-identical with or without inputAliases, so the canonical key keeps its place in required and the model is still told to use it.

Declare an alias where the meaning is certain and the mapping is one-to-one — a sibling tool's spelling for the same concept, the upstream API's own name, a shorthand weaker models reach for. It is not fuzzy matching: a key matching no alias and no declared key is still rejected by name, with the accepted-key hint. Four boundaries: a rewrite applies only when the target key is absent (alias and target present fails exactly as it does today); an author-opened root is never rewritten; a union root resolves against the variant the discriminator selects, and rewrites nothing when the discriminator is absent or unrecognized; and a headerParam-designated target is never rewritten to — the SDK cross-checks the Mcp-Param-<Name> header against the raw body before dispatch, so a later rewrite would hand your handler a value no intermediary attested. lint:mcp rejects an alias that shadows a declared key, names a target that does not exist or is headerParam-designated, or is ambiguous against another alias or key (input-alias-conflict).

3. A stringified array or object, or an integer sent for a string, is repaired after the parse fails. statusFilter: "[\"RECRUITING\"]" against z.array(z.string()), or target: "{\"type\":\"path\",\"path\":\"a.md\"}" against an object or discriminated-union field, is a serialization slip the server can undo with certainty — JSON.parse is the exact inverse of the JSON.stringify that produced it. So is station_id: 8654467 against z.string(): String(n) of a safe integer is the digits the caller sent. That certainty is what separates a repair from the nearest-key guessing strict input refuses. The repair runs only on the failure branch, only at the paths the rejection's own issues name, once — a value one repair produced is never repaired again, and a number inside one branch of a union field stays as sent — and is kept only if the repaired arguments then pass your schema. So it cannot touch a value that was already valid: a free-text field legitimately holding "[1,2,3]" or "{…}" is not in the issue list, so it survives untouched even when the same call carries a genuine stringified value in another field. The integer repair is gated further, to issues that say the value failed for being a number (a wrong type, a string-only enum, a union every branch of which refused the type): -1 against z.union([z.number().int().positive(), z.string()]) keeps its rejection instead of slipping past your constraint through the string branch, and -0, a fraction, or an integer past Number.MAX_SAFE_INTEGER is never repaired. Your schema still decides the repaired string — 20260922 against z.iso.date() stays rejected. It walks values only: no key is added, dropped, or renamed. When nothing validates, the original rejection is thrown — exactly what the same call gets under coerce: false.

When the drop took a key the alias stage wanted. The drop runs first, so it also discards an underscore spelling of a declared key (_query for query), a declared inputAliases: { _q: 'query' }, and an ignore-listed key a declared alias names. If the call then fails, repair included, the step reruns both stages with the alias stage first, where those keys are rewritten instead, and keeps that retry only if it validates, again with its own repair. The retry never case-folds an ignore-listed key onto a declared one (_meta stays the client's even beside a declared meta), and still drops an underscore key it cannot resolve. It never changes which calls validate: { query: 'abc', _max_results: '12345' } against an optional maxResults: z.number() validates with _max_results dropped, so the retry never runs. A call neither order validates gets the retry's rejection, which describes the arguments as the caller meant them: { _q: 'ab' } against query: z.string().min(3) reports the too-short query and closes Validated _q as query., not a missing query beside a dropped _q.

Turn any stage off per server — there is no per-tool switch:

ts
await createApp({
  input: {
    ignoreKeys: ['some_client_field'], // adds to the built-in list; `false` disables the stage
    caseStyleAliases: false,           // declared `inputAliases` only
    coerce: false,                     // never repair an argument value
  },
  tools: allToolDefinitions,
});

Those three stages are the whole of the framework's input edge: argument key names, and three value shapes — a JSON-stringified array or object, which JSON.parse inverts with certainty, and a safe integer sent for a string, whose digits String(n) restores. Every other value normalization is domain knowledge and belongs to the tool: the case or bare-leaf form of a code, a unit or vocabulary alias, a composite identifier assembled from two arguments, a delimiter-joined list, a spelled-out name. Which variants a given input accepts is decided per input at design time (design-mcp-server § Parameter descriptions) and applied at the head of the handler, on the unambiguous mappings only.

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),
});

The handler dispatches on the discriminator and TypeScript narrows input to that branch — input.id exists only under 'byId', and reaching for input.name there is a compile error.

What reaches the wire is {"type": "object", "oneOf": [<branch>, …]}: branches intact, each with its own required list and a const-tagged discriminator, additionalProperties: false on every one. Identical bytes on a 2025-11-25 and a 2026-07-28 connection — the legacy projection inspects outputSchema alone and never rewrites an input root.

Four constraints:

  • The union must be discriminated. A bare z.union(...) is rejected: with no literal-tagged key the model has nothing to choose a branch by, and every variant's required would read as applying at once.
  • output stays a flat z.object — see the widening section below for why a non-object output root breaks the success path. When the result shape varies by mode, use a kind discriminator with presence-based optional fields and render each arm on field presence in format().
  • Claude clients flatten the union root. The Anthropic Messages API rejects a top-level oneOf in input_schema, so Claude clients rewrite the root before the model sees it — and the rewrite keeps only the first branch's properties, with required: []. A tool that must work in Claude clients takes a flat z.object() with an enum discriminator, optional per-mode fields, each mode's required fields named in the discriminator's .describe(), and the combination checked in the handler. schema-root-oneof-portability (strict mode only) flags the union root. Tracked in #510.
  • A union root rules out headerParam. See below — the branches sit under oneOf, which the reachability rule excludes.
headerParam mirrors an argument into a request header

Protocol revision 2026-07-28 lets a tool designate an input property with x-mcp-header, so its value also rides an Mcp-Param-<Name> request header. A proxy, gateway, or router can then read it without parsing the JSON-RPC body:

ts
import { headerParam, tool, z } from '@cyanheads/mcp-ts-core';

input: z.object({
  query: z.string().describe('Search query.'),
  routing: z.object({
    region: headerParam(z.string(), 'Region').describe('Deployment region.'),
    shard: headerParam(z.int(), 'Shard-Id').describe('Shard the record lives on.'),
  }).describe('Where to route the lookup.'),
}),

The emitted property carries "x-mcp-header": "Region" and nothing else about the field changes — description, type, validation, and requiredness are untouched. Order does not matter: headerParam(z.string(), 'Region').describe('…') and headerParam(z.string().describe('…'), 'Region') are the same schema.

It mirrors, it does not relocate. When the body carries a value for a designated property, the matching Mcp-Param-<Name> header MUST be present and decode to an equal value; the SDK cross-checks the pair before dispatch and rejects a disagreement with -32020 (HeaderMismatch, HTTP 400). Absent or null in the body means no header is expected. Your handler still reads the argument from input — there is nothing new to do in the handler body. Browser clients need nothing extra either: the HTTP transport's CORS preflight allows Mcp-Param-<Name> for every designation on a registered tool, for each origin MCP_ALLOWED_ORIGINS accepts.

Where a designation is legal. The property must be primitive-typed (string, integer, number, boolean) and statically reachable through a chain of properties keys. Top-level and nested z.object() fields qualify. These do not:

PlacementWhy
An array element (z.array(z.object({ … })))Lives under items
A z.record() valueLives under additionalProperties
Any field of a discriminated-union input rootThe root advertises oneOf, so every branch is off the chain — no field of a union-input tool can be designated
A schema reused under .meta({ id })Hoisted into $defs and reached by $ref

Header names must be non-empty RFC 9110 tokens (no spaces, control characters, or HTTP delimiters) and case-insensitively unique across the whole input schema.

Violations fail at definition time. tool() throws on import, naming the field path and the reason. That is deliberate: the SDK only console.warns and registers the tool anyway, leaving conforming Streamable HTTP clients to drop it from tools/list — a tool that silently disappears with nothing reporting the gap. The linter reports the same verdict as header-param-designation for definitions assembled without the builder.

The advertised outputSchema is widened

The framework parses a successful result against the strict effective schema — output, extended with the enrichment block when one is declared — so a required field the handler never populated still fails loudly. What it advertises in tools/list is a widened projection of that schema: every success field optional, plus a declared error property describing the failure envelope.

The reason is client-side validation. A failing tool returns structuredContent: { error: … }, which can never satisfy a success-only schema; clients whose SDK validates structuredContent without first checking isError reject that envelope with -32602 before the error ever reaches the agent. Widening the advertised schema is the only fix a server can ship, because the validator runs in the caller.

The root stays type: 'object' (a discriminated union would emit anyOf with no type, which the 2025-era legacy projection rewrites — breaking the success path to fix the error path). The required list that the object form drops is recovered by an anyOf refinement in schema metadata: a result must satisfy either the success branch (success fields present, no error) or the failure branch (error present).

Practical consequence: do not read the advertised schema as the contract your handler must satisfy. output is still the contract. The widened form is emission only.

data.reason inside that envelope stays an unconstrained string. An errors[] contract covers what the handler throws, but a service it calls can raise its own reason (the SQL gate's denied_function, a parser's yaml_parse_failed), and that reaches the wire verbatim — an enum of the declared reasons would reject precisely those envelopes, recreating the -32602 the widening exists to prevent. The declared reasons are emitted as examples and spelled out in the description instead.

error is a reserved output field name. tool() throws if output or enrichment declares one: on the wire a failure is structuredContent.error, so a success payload using the same key cannot be told apart from a failure. Rename it (errorText, failureDetail).

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, folds the block into the tool's advertised outputSchema (see Schemas), 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.

typescript
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);   // → structuredContent.effectiveQuery + "Query: …" trailer
  ctx.enrich.total(res.total);   // → structuredContent.totalCount + "N total" trailer
  if (res.items.length === 0) ctx.enrich.notice(`No matches for "${input.query}".`);
  return { items: res.items };   // enrichment never rides in the domain return
},

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:

typescript
enrichment: {
  totalFound: z.number().describe('Matches before the page limit.'),
  appliedFilters: z.object({ /* … */ }).describe('Filters the server applied.'),
},
enrichmentTrailer: {
  totalFound: { label: 'Total Found' },                                  // → "**Total Found:** 2990"
  appliedFilters: { render: (f) => `### Filters\n- Range: ${f.dateRange}` }, // markdown, not JSON
},

structuredContent always keeps the full structured value; enrichmentTrailer only controls the human-facing content[] line.

Image / audio output belongs in ctx.content

When a tool produces image or audio bytes for the calling model to see or hear — a rendered chart, a generated frame, synthesized speech — emit them via ctx.content, not an output field. ctx.content.image(data, mimeType) / .audio(data, mimeType) prepend a content block to content[] after format() runs and never write to structuredContent, so the base64 is carried once instead of duplicating into the typed output. Like ctx.enrich, it lives on the base Context and is callable from the service layer.

ts
async handler(input, ctx) {
  const png = await render(input.spec);                 // base64 PNG
  ctx.content.image(png, 'image/png');                  // → content[] block, not structuredContent
  return { width: input.spec.w, height: input.spec.h }; // typed result stays small
},

The alternative — declaring previewData: z.string() in output and emitting the block from format() — ships the bytes twice (once in structuredContent, once in the block). Reserve output for data the agent reasons over; route raw media through ctx.content. Test with getContentBlocks(ctx). Full reference: framework-skills/api-context § ctx.content.

Capped lists must disclose truncation

When a tool accepts a cap-like input (limit, per_page, page_size, max_results, max_items) and returns an array, disclose when the cap was hit — the agent otherwise treats a partial set as complete.

The one-liner: ctx.enrich.truncated({ shown, cap }). Declare the fields in the enrichment block:

ts
enrichment: {
  truncated: z.boolean().describe('True when the list was capped at the limit.'),
  shown: z.number().describe('Number of items returned.'),
  cap: z.number().describe('The limit that was applied.'),
},
async handler(input, ctx) {
  const items = await fetchItems(input.limit);
  if (items.length >= input.limit) {
    ctx.enrich.truncated({ shown: items.length, cap: input.limit });
  }
  return { items };
},

Alternatively, if the upstream total is known, ctx.enrich.total(n) (writes totalCount) also satisfies the lint rule.

Threshold bound — when the upstream total is unknowable but the list is sorted by the cap key, the smallest shown value is a rigorous upper bound on all omitted items (Fagin Threshold Algorithm). Pass it as ceiling:

ts
// items is sorted descending by count; anything hidden has count ≤ items.at(-1).count
ctx.enrich.truncated({
  shown: items.length,
  cap: input.limit,
  ceiling: items.at(-1)?.count,
  guidance: 'Narrow with filters or raise per_page (max 200).',
});

Declare truncationCeiling: z.number().optional() in the enrichment block to surface it. The capped-list-no-truncation lint rule warns when this disclosure is absent — see api-linter.

Communicate filtering and exclusions

If the tool omitted, truncated, or filtered anything, say what and how to get it back. Silent omission is invisible to the agent — it can't act on what it doesn't know about.

typescript
output: z.object({
  items: z.array(ItemSchema).describe('Matching items (up to limit).'),
  totalCount: z.number().describe('Total matches before pagination.'),
  excludedCategories: z.array(z.string()).optional()
    .describe('Categories filtered out by default. Use includeCategories to override.'),
}),
Batch input and partial success

When a tool accepts an array of items, some may succeed while others fail. Report both — don't silently return successes and swallow failures.

typescript
// Output schema — design for per-item results
output: z.object({
  succeeded: z.array(ItemResultSchema).describe('Items that completed successfully.'),
  failed: z.array(z.object({
    id: z.string().describe('Item ID that failed.'),
    error: z.string().describe('What went wrong and how to resolve it.'),
  })).describe('Items that failed with per-item error details.'),
}),

// Handler — collect results, don't throw on individual failures
async handler(input, ctx) {
  const succeeded: ItemResult[] = [];
  const failed: { id: string; error: string }[] = [];

  for (const id of input.ids) {
    try {
      succeeded.push(await processItem(id));
    } catch (err) {
      failed.push({ id, error: err instanceof Error ? err.message : String(err) });
    }
  }

  return { succeeded, failed };
},

Note on the try/catch: this is the deliberate exception to the "logic throws, framework catches" rule. Per-item isolation is the whole point of partial-success batch tools — one failed item must not abort the batch, and the framework's partial-success telemetry (below) depends on seeing a populated failed array. Don't remove it to conform to the handler-level rule.

Single-item tools don't need this — they either succeed or throw. The partial success question only arises with array inputs.

Telemetry: The framework automatically detects this pattern — when a handler result contains a non-empty failed array, the span gets mcp.tool.partial_success, mcp.tool.batch.succeeded_count (from the succeeded array), and mcp.tool.batch.failed_count attributes. No manual instrumentation needed. An output built with partialResultSchema() from /utils is read under its own failedKey/succeededKey instead — also after .extend(), .pick(), .omit(), or a .shape spread. .partial() and .required() rebuild the fields, so a schema derived that way falls back to the literal keys.

Show full SKILL.md (2,729 more words)Show less
Empty results need context

An empty array with no explanation is a dead end. Echo back the criteria that produced zero results and suggest how to broaden. This is the canonical enrichment case — a notice is agent-facing context, not domain payload, and an empty result is a notice, not a throw:

typescript
// 1. Declare the notice as enrichment — reaches structuredContent AND content[],
//    no output field, no format() entry, no format-parity concern.
enrichment: {
  notice: z.string().optional()
    .describe('Recovery hint when results are empty — echoes filters and suggests how to broaden.'),
},

// 2. Handler — populate via ctx.enrich.notice() when the result is empty.
async handler(input, ctx) {
  const results = await search(input);
  if (results.length === 0) {
    ctx.enrich.notice(
      `No items matched status="${input.status}" in project "${input.project}". `
        + `Try a broader status filter or verify the project name.`,
    );
  }
  return { items: results, totalCount: results.length };
},

The notice lands in structuredContent.notice and renders as a content[] blockquote automatically — both surfaces, zero format() plumbing.

Mutator response design

Mutators (write/update/delete/append/patch verbs, or destructiveHint: true) surface raw pre- and post-mutation observable state — not a synthetic verdict. The server can detect anomalies but can't classify them as problems; only the agent knows whether file shrunk is intentional truncation or a bug.

typescript
output: z.object({
  path: z.string().describe('Resolved target path.'),
  created: z.boolean().describe('True when the operation created a new target.'),
  previousSizeInBytes: z.number().describe('Byte size before the mutation. Zero when created is true.'),
  currentSizeInBytes: z.number().describe('Byte size after the mutation. Equals previous when no-op.'),
}),

The agent reads created: true, previousSizeInBytes: 0, currentSizeInBytes: 68 and knows: brand new target, the full file content is the body. If that matches intent, fine; if not (typo path, uninitialized periodic note), the agent self-corrects without re-fetching. Anti-pattern: server-side >= integrity throws on mutators — the server can't distinguish intentional shrink from bug, so it throws on every shrink, including the deliberate ones.

When the before/after is agent-facing context rather than primary payload, the enrichment-native form is ctx.enrich.delta({ field, before, after }) — it writes { before, after } to structuredContent and renders **field:** before → after in the content[] trailer. Declare the field in the enrichment block as z.object({ before, after }); the linter recognizes the shape, so it needs no custom enrichmentTrailer.render. Same stance — surface raw state, never a verdict:

typescript
enrichment: {
  sizeInBytes: z.object({
    before: z.number().describe('Byte size before the mutation.'),
    after: z.number().describe('Byte size after the mutation.'),
  }).describe('Raw size before/after — the agent judges whether a shrink was intended.'),
},
// handler:
ctx.enrich.delta({ field: 'sizeInBytes', before: prev, after: next });
Sparse upstream data must stay honest

When tool output comes from a third-party API, don't overstate certainty. Upstream systems often omit fields entirely; the tool schema and format() should preserve that uncertainty instead of collapsing it into fake false, 0, or empty-string facts.

Guidance:

  • Use optional output fields when the upstream source is sparse.
  • Render unknown values explicitly (Not available, Unknown) instead of inventing a concrete value.
  • Only render booleans, badges, counts, and summary facts when they are actually known.
typescript
output: z.object({
  repos: z.array(z.object({
    id: z.string().describe('Repository ID.'),
    name: z.string().describe('Repository name.'),
    archived: z.boolean().optional()
      .describe('Archived status when provided by the upstream API. Omitted when unknown.'),
    stars: z.number().optional()
      .describe('Star count when provided by the upstream API. Omitted when unknown.'),
  })).describe('Repositories returned by the search.'),
}),

format: (result) => [{
  type: 'text',
  text: result.repos.map((repo) => [
    `## ${repo.name}`,
    `**ID:** ${repo.id}`,
    typeof repo.archived === 'boolean'
      ? `**Archived:** ${repo.archived ? 'Yes' : 'No'}`
      : '**Archived:** Not available',
    repo.stars != null
      ? `**Stars:** ${repo.stars}`
      : '**Stars:** Not available',
  ].join('\n')).join('\n\n'),
}],

A parsed value is the same problem one step later. Number(raw) over an absent or non-numeric upstream field yields NaN; a missing nested path yields null or undefined. Against a required z.number() / z.string() each of those fails the effective-output parse, and the agent gets an internal error in place of a record the tool otherwise had. Guard where the value is parsed, not at the schema: when a documented-sparse feed supplies nothing usable for a field, omit it (declare it .optional(), render it Not available) rather than passing a NaN, a null, or a coerced 0 into the return. Decide per field which upstream absences are expected — the honesty rule above, applied to values the server computes rather than copies.

Error classification and messaging

Recommended: declare an errors[] contract. A typed contract surfaces in tools/list and gives the handler a typed ctx.fail(reason, …) keyed by the declared reason union — TypeScript catches ctx.fail('typo') at compile time, data.reason is auto-populated and tamper-proof, and the linter enforces conformance against the handler body.

typescript
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';

export const fetchArticles = tool('fetch_articles', {
  description: 'Fetch articles by PMID.',
  errors: [
    { reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
      when: 'None of the requested PMIDs returned data.',
      recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
    { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
      when: 'Local request queue at capacity.', retryable: true,
      recovery: 'Wait 30 seconds and retry, or reduce batch size.' },
  ],
  input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
  output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
  async handler(input, ctx) {
    // Static recovery — the framework fills the contract recovery onto the wire,
    // mirrored into content[] text for format()-only clients.
    if (queue.full()) throw ctx.fail('queue_full');

    const articles = await fetch(input.pmids);
    if (articles.length === 0) {
      // Dynamic recovery — interpolate runtime context, override the contract default.
      throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, {
        pmids: input.pmids,
        recovery: { hint: `Use pubmed_search_articles to discover valid PMIDs.` },
      });
    }
    return { articles };
  },
});

The declared recovery reaches the wire on its own. When a failure whose data.reason names a contract entry leaves the handler without data.recovery, the framework sets data.recovery.hint to that entry's recovery — both client surfaces, the error log record, and runToolContract alike — whatever code it was thrown with. Pass { recovery: { hint: \…${dynamic}…` } }when you need runtime context; a throw-site hint always wins.ctx.recoveryFor(reason)still resolves the entry into{ recovery: { hint } }` for a hint that must ride the thrown error itself. The contract is the single source of truth — write the recovery once, lint validates it ≥5 words, the framework carries it to every failure with that reason.

Baseline codes (InternalError, ServiceUnavailable, Timeout, ValidationError, SerializationError) bubble freely and don't need declaring. Wire-level behavior is identical when the contract is omitted, but you lose the type-checked ctx.fail, the tools/list advertisement, and conformance lint coverage — declare a contract whenever the tool has a domain-specific failure mode.

ctx.fail accepts an optional 4th options argument for ES2022 cause chaining: throw ctx.fail('upstream_error', 'Upstream returned 500', { url }, { cause: e }).

Service-layer throws

API-wrapping tools usually delegate to a service: await ncbi.fetch(input, ctx). The throw lives in the service, not the handler. Services accept ctx (the unified Context) so they can call ctx.log, ctx.state, etc. The handler doesn't catch — it just bubbles, and the framework's auto-classifier preserves data on the wire.

The contract entry on the tool and the data: { reason } on the service throw need to use the same reason string so the two sides line up. That reason is all it takes: the framework fills the calling tool's declared recovery onto the failure as it leaves the handler.

typescript
// service — receives ctx; passes data.reason
import type { Context } from '@cyanheads/mcp-ts-core';
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';

export class NcbiService {
  async fetch(pmids: string[], ctx: Context) {
    const response = await fetchWithRetry(...);
    if (!response.ok) {
      throw serviceUnavailable(`NCBI returned HTTP ${response.status}`, {
        reason: 'ncbi_unreachable',  // the caller's declared recovery is filled from this
        status: response.status,
      });
    }
    return response.json();
  }
}

// tool — declares the matching contract entry, calls the service, doesn't catch
export const fetchArticles = tool('fetch_articles', {
  errors: [
    { reason: 'ncbi_unreachable', code: JsonRpcErrorCode.ServiceUnavailable,
      when: 'NCBI E-utilities is unreachable.', retryable: true,
      recovery: 'NCBI is degraded; retry in a few minutes.' },
  ],
  async handler(input, ctx) {
    return { articles: await ncbi.fetch(input.pmids, ctx) };  // throws bubble unchanged
  },
});

A tool that declares no matching entry gets no hint — the service doesn't have to know which tool called it.

Add thrownBy: 'service' to a contract entry the service produces once the handler also throws one of its own. error-contract-unthrown reads the handler body alone: as soon as one literal ctx.fail( appears there, every declared reason the body does not name is flagged, and the marker is what tells the rule this one is thrown a layer down. Lint-only metadata — the entry stays typed, advertised, and thrown exactly as an unmarked one.

See add-service for the full pattern.

Ad-hoc factory throws (fallback)

When no contract entry fits — prototype code, one-off throws, or service-layer fallbacks — use error factories or plain throw new Error(). The framework auto-classifies plain Error from message patterns as a last resort.

typescript
// Client input error — agent can fix and retry
import { validationError, notFound } from '@cyanheads/mcp-ts-core/errors';
throw validationError(`Invalid date format: "${input.date}". Expected YYYY-MM-DD.`);

// Not found — valid input but entity doesn't exist
throw notFound(
  `Project "${input.slug}" not found. Check the slug or use project_list to see available projects.`
);

// Upstream API — transient, may resolve on retry
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
throw serviceUnavailable(`arXiv API returned HTTP ${status}. Retry in a few seconds.`);

// Recovery hint via the canonical `data.recovery.hint` shape — the framework
// mirrors it into the content[] text as `Recovery: <hint>`, so format()-only
// clients (Claude Desktop) see the same guidance that structuredContent clients
// (Claude Code) read from `error.data.recovery.hint`. A hint the message already
// contains verbatim is dropped from the text rather than stated twice; it stays
// on structuredContent regardless. `data.reason`, `data.retryable`, and the
// framework's own `data.requestId` render as a closing
// `(reason … · not retryable · request <id>)` line; other `data` keys reach
// structuredContent only.
import { invalidParams } from '@cyanheads/mcp-ts-core/errors';
throw invalidParams(
  `Date range exceeds 90-day API limit.`,
  {
    maxDays: 90,
    requestedDays: daysBetween,
    recovery: { hint: 'Narrow the range or split into multiple queries.' },
  },
);

Error messages are recovery instructions. Name what went wrong, why, and what action to take. The message is the agent's only signal — a bare "Not found" is a dead end. See framework-skills/api-errors/SKILL.md for the full contract pattern, factories list, auto-classification table, and error-path parity (how data.recovery.hint reaches both client surfaces).

Include operational metadata

Counts, applied filters, truncation notices, and chaining IDs help the agent decide its next action without extra round trips.

Counts, applied-filter summaries, and query echo that describe the result set (rather than being the result) are textbook enrichment — ctx.enrich.total(n), ctx.enrich.echo(parsedQuery), or ctx.enrich({ appliedFilters }) put them on both client surfaces with no format() entry (a structured field like appliedFilters also needs an enrichmentTrailer.render so its trailer line is markdown, not a JSON blob — see Tool Response Design). Reserve domain output for the payload itself and post-action state (e.g. currentStatus after a write), as below:

typescript
return {
  commits: formattedCommits,
  total: allCommits.length,
  shown: formattedCommits.length,
  fromRef: input.from,
  toRef: input.to,
  // Post-write state — saves a follow-up status call
  ...(input.operation === 'commit' && { currentStatus: await getStatus() }),
};

Seed orientation context when the next moves are predictable. Piggybacking a compact snapshot alongside the primary result — recent activity, tracked state, a few reference items — does two things: cuts a predictable follow-up call and primes the LLM on the project's conventions (recent commits teach the commit-message style the agent should match; recent tags teach the versioning format; reference records teach the naming format). Natural fits include session open/close tools, state-changing verbs where post-action confirmation helps, and entry points that drop the agent into a new scope. Gather sub-operations with Promise.allSettled and surface per-component failures as a warnings array rather than failing the outer call. See design-mcp-server's Output design for the full principle.

Defend against empty values from form-based clients

LLM clients (Claude, Cursor, etc.) only send populated fields. Form-based clients (MCP Inspector, web UIs) submit the full schema shape — optional object fields arrive with empty-string inner values instead of undefined. Zod's .optional() only rejects undefined, so { minDate: "", maxDate: "" } passes validation and reaches the handler.

Don't reject empty strings on optional fields — that punishes form clients for valid MCP behavior. Instead, guard for meaningful values in the handler:

typescript
// Schema: keep permissive — accepts empty strings from form clients
input: z.object({
  query: z.string().describe('Search terms'),
  dateRange: z.object({
    minDate: z.string().describe('Start date (YYYY-MM-DD)'),
    maxDate: z.string().describe('End date (YYYY-MM-DD)'),
  }).optional().describe('Restrict results to a date range.'),
}),

// Handler: check for meaningful values, not just object presence
async handler(input, ctx) {
  const params: Record<string, string> = { query: input.query };
  if (input.dateRange?.minDate && input.dateRange?.maxDate) {
    params.minDate = input.dateRange.minDate;
    params.maxDate = input.dateRange.maxDate;
  }
  // ...
},

The same applies to optional arrays — use ?.length guards so empty arrays are skipped, not passed through.

When an optional string field carries a validator (.regex() for a date, .min(1) for a cursor), a permissive schema would drop the validator and a strict one would reject the blank. Keep both by mapping the blank to undefined before the validator runs:

typescript
/** A blank from a form client is "unset", never a value to validate. */
const blankAsUnset = <T extends z.ZodType>(schema: T) =>
  z.preprocess((value) => (value === '' ? undefined : value), schema);

input: z.object({
  d1: blankAsUnset(z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()).describe('Earliest date, YYYY-MM-DD.'),
  cursor: blankAsUnset(z.string().regex(/^[1-9]\d*$/).optional()).describe('Opaque continuation from the previous page.'),
}),

toJSONSchema emits only the inner schema for a preprocess pipe in both io modes, so the advertised pattern is unchanged; '' parses to undefined, a real value still hits the validator, and a handler that tests the value (input.d1 ?? default, if (input.d1)) needs no extra guard. The key itself stays present ('d1' in input is true), so never test for the key or spread input into an upstream query. Because the advertised pattern still rejects '', keep the blank out of the .describe() text: "an empty string means unset" contradicts the schema a validating client reads. The blank is tolerated for form clients, not offered as input.

Required fields are different. If a string field is required and must be non-empty to be meaningful, .min(1) is correct — the client shouldn't have submitted the form without filling it in.

Match response density to context budget

Large payloads burn the agent's context window. Default to curated summaries; offer full data via opt-in parameters.

  • Lists: Return top N with a total count and pagination cursor, not unbounded arrays
  • Large objects: Return key fields by default; accept a fields or verbose parameter for full data
  • Binary/blob content: Return metadata and a reference, not the raw content
  • Analytical working sets: When upstream returns more analytical rows (data an agent would SQL — aggregate, group, join) than fit in context, DataCanvas (core.canvas, wired in setup() via setCanvas; Tier 3 — opt-in via CANVAS_PROVIDER_TYPE=duckdb) lets you register the rows and return the canvas_id plus a preview so the agent can run SQL to slice down without a re-fetch. The spillover() helper (@cyanheads/mcp-ts-core/canvas) automates the overflow case: drain rows up to a character budget for the inline preview, auto-register the full source on overflow, return both as a discriminated union. Two gates: it must be analytical, not a discovery/search surface of categorical metadata (those don't earn a canvas regardless of row count — use MCP-side list filtering or pagination); and a tool emitting a canvas_id MUST be paired with a registered dataframe_query tool, or the handle is unreachable. Compute distributions or refinement hints across the full result — not the preview — so the agent gets honest aggregate signal on the rows it didn't read. Declare the input canvas_id field with CanvasIdSchema (@cyanheads/mcp-ts-core/canvas) rather than a bare z.string(): it advertises the 10-character URL-safe pattern in inputSchema, so a model sees the shape before it calls and a value that could never be an id is rejected at argument validation instead of after a registry lookup. Add your own .describe() over it to say which tool produced the id. The output field stays a plain z.string() — that id came from the server. See api-canvas for the register / query / export pattern and the spillover flow.
  • One large document: When a single call returns one document-shaped record (not a row set) that can overflow context, return a section outline — top-level keys + per-section byte size — and let the agent re-call with sections: [...] for only what it needs, instead of truncating one surface. outlineOnOverflow() with OUTLINE_VARIANT / selectSections() / formatOutline() (@cyanheads/mcp-ts-core/utils) measures the payload and returns a full | outline result. Declare the tool's output as a flat z.object with a kind discriminator and presence-based optional arms (fold in OUTLINE_VARIANT.shape.sections / .notice) — tool() rejects a z.discriminatedUnion output — and render each arm on field presence in format() so parity holds. Pure measure + key-slice — Workers-portable, unlike canvas spillover(). Use for one fat record; use spillover() for a row collection. See the techniques skill's outline-on-overflow reference.

MCP-side list filtering

When an upstream API has no native search but the relevant set is bounded (fits one or a few fetches), fetch it in full and filter on the server so an agent resolves a name → opaque ID in one call instead of scanning a blob. The design-mcp-server skill covers when to reach for this (the earns-its-keep gate, the query-vs-local-filter split); this is the how.

Name the local param for the mechanic — filter or nameContains, distinct from an upstream query. Filter the complete set, not the page (fetch up to the cap first). Strict token match is the default — normalize, then require every query token to appear; that handles word order and partials, needs no fuzzy library, and is too small to extract into a shared helper:

typescript
const normalize = (s: string) =>
  s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9\s]/g, ' ');

// Filter the full bounded set — not a single page.
const tokens = normalize(input.nameContains).split(/\s+/).filter(Boolean);
const hits = items.filter((it) => {
  const hay = normalize(it.name);
  return tokens.every((t) => hay.includes(t));
});
if (hits.length === 0) {
  ctx.enrich.notice(
    `No name matched "${input.nameContains}". Call the tool without a filter to browse the full list.`,
  );
}
return { items: hits };

Add a fuzzy fallback only when a caller genuinely needs typo tolerance — an LLM caller rarely does. If you do: fire it only when the strict match is empty, score against the best-matching token in each name (not the whole string) and cap the results, and label hits approximate. Test it against a full-scale fixture with a deliberate near-miss — a small fixture has no long-name noise floor, so a unit test won't catch a fallback that returns dozens of bogus matches. A bare "no match — browse the unfiltered list" often beats an approximate guess: it lets the model self-correct rather than commit to the wrong record.

Checklist

  • File created at src/mcp-server/tools/definitions/{{tool-name}}.tool.ts
  • Tool name passed to tool() uses snake_case
  • title field set
  • annotations set correctly — readOnlyHint: false for write tools, destructiveHint: true for delete/overwrite tools
  • All Zod schema fields have .describe() annotations
  • Numeric output fields carry units in the field name (sizeInBytes, durationInMs, priceInCents, latencyInMs) — .describe() may be summarized away or truncated, but the field name persists into the JSON the agent reads. Exempt: dimensionless counts (totalCount, itemCount), indices (index, position)
  • Schemas use only JSON-Schema-serializable types (no z.custom(), z.date(), z.transform(), z.bigint(), z.symbol(), z.void(), z.map(), z.set())
  • JSDoc @fileoverview and @module header present
  • Optional nested objects guarded for empty inner values from form-based clients (check ?.field truthiness, not just object presence)
  • No console calls — use ctx.log for handler logging
  • handler(input, ctx) is pure — throws on failure, no try/catch (exception: batch tools with per-item isolation use try/catch inside the loop — that's intentional, don't remove it)
  • format() renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with format-parity errors otherwise. Different clients forward different surfaces (Claude Code → structuredContent, Claude Desktop → content[]); both must carry the same data. Primary fix: render the missing field in format() (for list/detail variants, one flat z.object with a kind discriminator and presence-based optional arms rendered by independent if blocks — tool() rejects a z.discriminatedUnion output). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (z.object({}).passthrough()) rather than maintaining aspirational typing
  • Agent-facing context (empty-result notices, query/filter echo, pagination totals) declared in an enrichment block and populated via ctx.enrich(...) — reaches both structuredContent and content[] automatically, not authored solely in format() text. Enrichment keys disjoint from output keys
  • If wrapping external API: output schema and format() preserve uncertainty from sparse upstream payloads instead of inventing concrete values, and a parsed NaN/null is dropped at the parse site rather than passed to a required output field
  • auth scopes declared if the tool needs authorization
  • errors: [...] contract declared for the tool's domain-specific failure modes — or block deleted if no domain failures apply (baseline codes bubble freely)
  • Error contract declared inline on this tool — not imported from a shared module, even when other tools have near-identical entries
  • Long loops check ctx.signal.aborted so a cancelled request (or a closed transport) stops the work
  • If the tool needs caller input it may not have been given: reads ctx.inputs first, requests only what is missing via return ctx.requestInput(...), and treats a declined/cancelled response as terminal rather than re-asking. A destructive confirmation redeems a ctx.state consent record bound to the operation, caller, and target (api-context § Consent gates) rather than trusting the answer alone
  • If tool returns unbounded arrays: pagination with total count, or spillover() / DataCanvas for analytical working sets (an agent would SQL them — not a discovery/search surface). If any tool emits a canvas_id, a dataframe_query tool is registered in the same server — a token with no query tool is dead output
  • If tool returns one large document (not a row set) that can overflow context: outlineOnOverflow() returns a full | outline union so the agent re-calls with sections: [...] — not one-sided truncation
  • If tool is feature-gated: evaluated whether disabledTool() wrapper is appropriate (present in manifest but uncallable)
  • If a tool is gated off: swept the server for its name — no prose calls it available, no recovery hint routes to it, and every structured suggestion naming it is emitted only under the config that registers it
  • If the tool filters a bounded list locally (no upstream search): a distinct local param (filter/nameContains, not query), filters the full set (not one page), strict token match by default
  • Registered in the project's existing createApp() tool list (directly or via barrel)
  • Test file created via add-test skill, or handler tested directly with createMockContext()
  • bun run devcheck passes
  • Smoke-tested with bun run rebuild && bun run start:stdio < /dev/null (or start:http); the Core services constructed record lists the new tool in its tools field

© cyanheads, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in framework-skills/add-tool of cyanheads/pubmed-mcp-server.

Open the folder on GitHubat commit 5a417fb

Compare with similar skills

Add Tool next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Add Tool compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Add Tool this skillcyanheads/pubmed-mcp-server156—~17kAutomated safety check: PassApache-2.0
Setting Up Papergraphlotchuazzz-crypto/papergraph-mcp285—~3.3kAutomated safety check: PassMIT
Just PRs MCPClawBio/ClawBio1.2k—~3.5kAutomated safety check: PassMIT
Patsnap Current Awarenesspatsnap/mcp113—~671Automated safety check: PassApache-2.0
Patsnap Scientific Translational Evidencepatsnap/mcp113—~728Automated safety check: PassApache-2.0
Peer Review Loophashgraph-online/awesome-codex-plugins1.3k—~2.3kAutomated safety check: PassApache-2.0

Similar skills

  • Setting Up Papergraph

    lotchuazzz-crypto/papergraph-mcp

    A skill your agent uses when a user has cloned PaperGraph MCP and asks to install, initialize, configure, set up, or start using it with an agent or MCP client.

    285 GitHub stars~3.3k tokensUpdated 2 days ago
    Agent WorkflowsAuto-check passed
  • Just PRs MCP

    ClawBio/ClawBio

    Compute evidence-aware polygenic risk scores from a local VCF or WGS file through the validated just-prs engine and a pinned local just-prs MCP server.

    1.2k GitHub stars~3.5k tokensUpdated yesterday
    Agent WorkflowsAuto-check passed
  • Patsnap Current Awareness MCP for AI agents. An agent skill from patsnap/mcp.

    113 GitHub stars~671 tokensUpdated 1 mo ago
    Agent WorkflowsAuto-check passed
  • Peer Review Loop

    hashgraph-online/awesome-codex-plugins

    Peer Review Ralph Loop — combines Cavekit kits with a Ralph Loop and true cross-model peer review using Codex (OpenAI).

    1.3k GitHub stars~2.3k tokensUpdated today
    Agent WorkflowsAuto-check passed
  • MCP Server Builder

    anthropics/skills

    Official

    Guides the design and implementation of Model Context Protocol servers in TypeScript or Python, from tool naming and error messages to evaluation.

    180k GitHub starsUsed in 63 repos~2.3k tokens
    Agent WorkflowsAuto-check passed

More from cyanheads/pubmed-mcp-server

All 30 skills in this repo
  • Add App Tool

    cyanheads/pubmed-mcp-server

    Scaffold an MCP App tool + UI resource pair. An agent skill from cyanheads/pubmed-mcp-server.

    156 GitHub stars~3.2k tokensUpdated 5 days ago
    Auto-check passed
  • Add Prompt

    cyanheads/pubmed-mcp-server

    Scaffold a new MCP prompt template. An agent skill from cyanheads/pubmed-mcp-server.

    156 GitHub stars~1.6k tokensUpdated 5 days ago
    Auto-check passed
  • Add Resource

    cyanheads/pubmed-mcp-server

    Scaffold a new MCP resource definition. An agent skill from cyanheads/pubmed-mcp-server.

    156 GitHub stars~3k tokensUpdated 5 days ago
    Auto-check passed
  • Add Service

    cyanheads/pubmed-mcp-server

    Scaffold a new service integration. An agent skill from cyanheads/pubmed-mcp-server.

    156 GitHub stars~3.6k tokensUpdated 5 days ago
    Auto-check passed
  • Add Test

    cyanheads/pubmed-mcp-server

    Scaffold a test file for an existing tool, resource, or service.

    156 GitHub stars~4.1k tokensUpdated 5 days ago
    Auto-check passed
  • API Auth

    cyanheads/pubmed-mcp-server

    Authentication, authorization, and multi-tenancy patterns for @cyanheads/mcp-ts-core.

    156 GitHub stars~2.7k tokensUpdated 5 days ago
    Auto-check passed

Questions about Add Tool

What does Add Tool do?

Scaffold a new MCP tool definition. An agent skill from cyanheads/pubmed-mcp-server. Add Tool is an agent skill from cyanheads/pubmed-mcp-server. Scaffold a new MCP tool definition.

When should I use Add Tool?

Add Tool fits situations like: the user asks to add a tool; create a new tool; implement a new capability for the server.

How do I install Add Tool in Claude Code?

Run `npx skills add cyanheads/pubmed-mcp-server --skill add-tool -a claude-code`. Or copy the skill folder (framework-skills/add-tool in cyanheads/pubmed-mcp-server) into .claude/skills/add-tool in your project. Claude Code loads it when a task matches its description.

How do I install Add Tool in Codex?

Run `npx skills add cyanheads/pubmed-mcp-server --skill add-tool -a codex`. Or copy the skill folder (framework-skills/add-tool in cyanheads/pubmed-mcp-server) into .agents/skills/add-tool in your project. Codex loads it when a task matches its description.

Can I use Add Tool in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add cyanheads/pubmed-mcp-server --skill add-tool -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/add-tool, .gemini/skills/add-tool, .github/skills/add-tool and .opencode/skills/add-tool in your project.

What does Add Tool need to run?

Going by SKILL.md and its folder, Add Tool needs the command-line tools its instructions call (bun) and credentials named MCP_REQUEST_STATE_KEY. Our summary lists: A credential in MCP_REQUEST_STATE_KEY.

Does Add Tool access the network?

SKILL.md names 1 domain. As links in the text: github.com. This is read from the text; nothing was executed.

Is Add Tool safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Add Tool use?

Add Tool is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Add Tool use?

About 17k tokens (SKILL.md is roughly 69k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Add Tool?

Skills that share tags, products or a category with Add Tool: Setting Up Papergraph (lotchuazzz-crypto/papergraph-mcp, 285 stars), Just PRs MCP (ClawBio/ClawBio, 1.2k stars), Patsnap Current Awareness (patsnap/mcp, 113 stars) and Patsnap Scientific Translational Evidence (patsnap/mcp, 113 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Add Tool?

cyanheads (a GitHub user) maintains it in cyanheads/pubmed-mcp-server, which has 156 GitHub stars. The repository holds 30 skills in this directory. The repository was last updated on October 4, 2026.

Source: cyanheads/pubmed-mcp-server on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.