This is the documentation for the v2 beta — looking for the v1 documentation?
Skip to content

Tools

A tool is an action a connected client — and the model driving it — can invoke on your server.

Add a tool

registerTool takes a name, a config, and a handler. inputSchema is a Zod schema — the only schema you write.

ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const catalog = [
    { name: 'Espresso cup', price: 12 },
    { name: 'Travel mug', price: 24 },
    { name: 'Mug rack', price: 36 }
];

const server = new McpServer({ name: 'catalog', version: '1.0.0' });

server.registerTool(
    'search',
    {
        description: 'Search the product catalog',
        inputSchema: z.object({
            query: z.string().describe('Substring to match against product names'),
            limit: z.number().int().max(50).optional()
        })
    },
    async ({ query, limit }) => {
        const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
        const names = hits.slice(0, limit ?? 10).map(product => product.name);
        return { content: [{ type: 'text', text: names.join('\n') }] };
    }
);

From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types.

tools/list now advertises search, and the SDK has already parsed every call that reaches your handler.

TIP

.describe() survives the conversion: the JSON Schema advertised for query carries Substring to match against product names as its description — the only documentation the model gets for that argument.

Coming from v1?

registerTool replaces tool() — run the codemod, then see the upgrade guide.

Call it

Every call on this page comes from an in-memory Client connected to the server above — Test a server shows that wiring — and an MCP host does the same over stdio or HTTP. Call the tool with valid arguments.

ts
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
console.log(result.content);

The handler's content comes back unchanged:

[ { type: 'text', text: 'Travel mug\nMug rack' } ]

Send arguments the schema rejects

Change one argument: a limit the schema caps at 50.

ts
const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } });
console.log(rejected);

The SDK rejects the arguments before your handler runs:

{
  content: [
    {
      type: 'text',
      text: 'Input validation error: Invalid arguments for tool search: limit: Too big: expected number to be <=50'
    }
  ],
  isError: true
}

The rejection is an ordinary tool result with isError: true, so the model reads the message and retries with arguments that fit the schema. Thrown errors and protocol-level failures are their own topic — see Errors.

Return structured output

Add outputSchema and return the matching value as structuredContent, next to the human-readable content.

ts
server.registerTool(
    'product-details',
    {
        description: 'Look up one product by its exact name',
        inputSchema: z.object({ name: z.string() }),
        outputSchema: z.object({ name: z.string(), price: z.number() })
    },
    async ({ name }) => {
        const product = catalog.find(candidate => candidate.name === name);
        if (!product) throw new Error(`No product named ${name}`);
        const output = { name: product.name, price: product.price };
        return {
            content: [{ type: 'text', text: JSON.stringify(output) }],
            structuredContent: output
        };
    }
);

The SDK validates structuredContent against outputSchema before the result leaves your server, and advertises the derived JSON Schema in tools/list so clients can validate it too.

Calling product-details with { name: 'Travel mug' } returns both renderings:

{
  content: [ { type: 'text', text: '{"name":"Travel mug","price":24}' } ],
  structuredContent: { name: 'Travel mug', price: 24 }
}

The wire encoding of structured results differs by protocol era — see Protocol versions.

Annotate the tool

title is the display name; annotations are behavior hints for the client.

ts
server.registerTool(
    'clear-catalog',
    {
        title: 'Clear the catalog',
        description: 'Remove every product from the catalog',
        annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
    },
    async () => {
        catalog.length = 0;
        return { content: [{ type: 'text', text: 'Catalog cleared' }] };
    }
);

A tool that takes no arguments omits inputSchema. Annotations never change how the SDK runs the tool — clients use them to decide what to put in front of the end user: a host can auto-approve a read-only tool and require confirmation before a destructive one.

Recap

  • registerTool(name, config, handler) registers a tool; inputSchema is a Zod object schema.
  • The one schema yields the advertised JSON Schema, argument validation, and the handler's argument types.
  • Arguments that fail the schema come back as an isError: true tool result; the handler never runs.
  • outputSchema plus structuredContent add machine-readable results, validated before they leave the server.
  • title and annotations describe the tool to clients and never change execution.