Agent skill

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.

Stars 131
Forks 24

Install this agent skill to your Project

npx add-skill https://github.com/cyanheads/mcp-ts-core/tree/main/skills/add-tool

Metadata

Additional technical details for this skill

type
reference
author
cyanheads
version
1.3
audience
external

SKILL.md

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 and is registered into createApp() in src/index.ts. Some larger repos later add definitions/index.ts barrels; match the pattern already used by the project you're editing.

For the full tool() API, Context interface, and error codes, read:

node_modules/@cyanheads/mcp-ts-core/CLAUDE.md

Steps

  1. Ask the user for the tool's name, purpose, and input/output shape
  2. Determine if long-running — if the tool involves streaming, polling, or multi-step async work, it should use task: true
  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
  6. Smoke-test with bun run dev:stdio or dev:http

Template

typescript
/**
 * @fileoverview {{TOOL_DESCRIPTION}}
 * @module mcp-server/tools/definitions/{{TOOL_NAME}}
 */

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

export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
  title: '{{TOOL_TITLE}}',
  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.
  }),
  // auth: ['tool:{{tool_name}}:read'],

  async handler(input, ctx) {
    ctx.log.info('Processing', { /* relevant input fields */ });
    // Pure logic — throw on failure, no try/catch
    return { /* output */ };
  },

  // format() populates MCP content[] — the only field most LLM clients forward
  // to the model. structuredContent (from output) is for programmatic use only.
  // Render ALL data the LLM needs to reason about the result.
  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') }];
  },
});

Task tool variant

Add task: true and use ctx.progress for long-running operations:

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

  async handler(input, ctx) {
    await ctx.progress!.setTotal(totalSteps);
    for (const step of steps) {
      if (ctx.signal.aborted) break;
      await ctx.progress!.update(`Processing: ${step}`);
      // ... do work ...
      await ctx.progress!.increment();
    }
    return { /* output */ };
  },
});

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.

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.

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

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, and mcp.tool.batch.failed_count attributes. No manual instrumentation needed.

Empty results need context

An empty array with no explanation is a dead end. Echo back the criteria that produced zero results and, where possible, suggest how to broaden the search.

typescript
// In handler — after getting zero results:
if (results.length === 0) {
  return {
    items: [],
    totalCount: 0,
    message: `No items matched status="${input.status}" in project "${input.project}". `
      + `Try a broader status filter or verify the project name.`,
  };
}

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'),
}],

Error classification and messaging

The framework auto-classifies many errors at runtime (HTTP status codes, JS error types, common patterns). Use explicit error factories when you want a specific code and clear recovery guidance; plain throw new Error() when auto-classification is sufficient.

Classify by origin — different sources need different codes:

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.`);

// Structured hint for programmatic recovery
throw new McpError(JsonRpcErrorCode.InvalidParams,
  `Date range exceeds 90-day API limit. Narrow the range or split into multiple queries.`,
  { maxDays: 90, requestedDays: daysBetween },
);

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.

Include operational metadata

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

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

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.

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

Checklist

  • File created at src/mcp-server/tools/definitions/{{tool-name}}.tool.ts
  • All Zod schema fields have .describe() annotations
  • 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)
  • handler(input, ctx) is pure — throws on failure, no try/catch
  • format() renders all data the LLM needs (not just a count or title) — content[] is the only field most clients forward to the model
  • If wrapping external API: output schema and format() preserve uncertainty from sparse upstream payloads instead of inventing concrete values
  • auth scopes declared if the tool needs authorization
  • task: true added if the tool is long-running
  • Registered in the project's existing createApp() tool list (directly or via barrel)
  • bun run devcheck passes
  • Smoke-tested with bun run dev:stdio or dev:http

Expand your agent's capabilities with these related and highly-rated skills.

cyanheads/mcp-ts-core

add-resource

Scaffold a new MCP resource definition. Use when the user asks to add a resource, expose data via URI, or create a readable endpoint.

131 24
Explore
cyanheads/mcp-ts-core

field-test

Exercise tools, resources, and prompts with real-world inputs to verify behavior end-to-end. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface. Calls each definition with realistic and adversarial inputs and produces a report of issues, pain points, and recommendations.

131 24
Explore
cyanheads/mcp-ts-core

release

Verify release readiness and publish. The git wrapup protocol handles version bumps, changelog, README, commits, and tagging during the coding session. This skill verifies nothing was missed, runs final checks, and presents the irreversible publish commands.

131 24
Explore
cyanheads/mcp-ts-core

add-export

Add a new subpath export to the @cyanheads/mcp-ts-core package. Use when creating a new public API surface that consumers import from a dedicated subpath (e.g., @cyanheads/mcp-ts-core/newutil).

131 24
Explore
cyanheads/mcp-ts-core

api-errors

McpError constructor, JsonRpcErrorCode reference, and error handling patterns for `@cyanheads/mcp-ts-core`. Use when looking up error codes, understanding where errors should be thrown vs. caught, or using ErrorHandler.tryCatch in services.

131 24
Explore
cyanheads/mcp-ts-core

api-utils

API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.

131 24
Explore

Didn't find tool you were looking for?

Be as detailed as possible for better results