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.
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.
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.
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.
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.
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;inputSchemais 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: truetool result; the handler never runs. outputSchemaplusstructuredContentadd machine-readable results, validated before they leave the server.titleandannotationsdescribe the tool to clients and never change execution.