
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
registerTool is how you add a tool to an MCP server in the TypeScript SDK v2. It takes three arguments: a name, a config object and an async handler. The config holds the title, description, inputSchema, outputSchema and annotations, and each one changes what clients see or how the SDK checks your data.
This page goes through every option, with a working two-tool server you can copy. It also covers the error paths and how to move v1 server.tool() code over.
The examples follow the SDK's Tools guide and Errors guide, checked on 2 November 2026. The code below ran on 2 November 2026 with @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/client 2.4.0, zod 4.6.7, tsx 4.23.15 and Node.js 24.16.0. The JSON outputs shown are from that run.
Key Takeaways
- Install
@modelcontextprotocol/server(v2), not@modelcontextprotocol/sdk(v1), for new servers. inputSchemais a Zod object: the SDK turns it into the advertised JSON Schema, validates arguments and types your handler.outputSchemaplusstructuredContentgives clients typed results; the SDK checks the result before it leaves the server.annotationsare hints for the host's approval UI and never change how the SDK runs the tool.- Bad input, a returned
isError: trueand a thrown error all reach the model as readable tool results. - v1
server.tool()is removed in v2; the official codemod rewrites it toregisterTool.
The signature at a glance
server.registerTool(name, config, handler);
| Part | Field | What it does |
|---|---|---|
| name | string | The ID the model calls. Keep it short and stable. |
| config | title | Display name for people in the host UI. |
| config | description | What the model reads to decide when to call the tool. |
| config | inputSchema | Zod object for arguments. Omit it for tools with no arguments. |
| config | outputSchema | Zod object for structuredContent. Optional. |
| config | annotations | Hints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. |
| handler | async function | Gets parsed arguments, returns content and optionally structuredContent or isError. |
If you have never built a TypeScript MCP server, start with Lesson 15: your first tool in TypeScript. It covers project setup, stdio and compiling for a host. This page assumes that groundwork.
Setup
npm install @modelcontextprotocol/server zod
npm install -D tsx
Set "type": "module" in package.json. The upgrade guide says Zod v3 is no longer supported and recommends Zod 4.2.0 or later. On Zod 4.0 and 4.1, it says .describe() text can be dropped from the generated schema.
A complete example
This server has a read-only lookup tool and a destructive cancel tool. Save it as server.ts:
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const orders = new Map([
['A-1001', { status: 'shipped', total: 42.5 }],
['A-1002', { status: 'processing', total: 18 }]
]);
export function createServer(): McpServer {
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.registerTool(
'get_order_status',
{
title: 'Get order status',
description: 'Look up one order by its ID (for example A-1001). Returns status and total.',
inputSchema: z.object({
orderId: z.string().regex(/^A-\d{4}$/).describe('Order ID, format A-1234')
}),
outputSchema: z.object({
orderId: z.string(),
status: z.enum(['processing', 'shipped', 'cancelled']),
total: z.number()
}),
annotations: { readOnlyHint: true, openWorldHint: false }
},
async ({ orderId }) => {
const order = orders.get(orderId);
if (!order) {
return {
content: [{ type: 'text', text: `No order ${orderId}. Check the ID and try again.` }],
isError: true
};
}
const output = { orderId, status: order.status as 'processing' | 'shipped' | 'cancelled', total: order.total };
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output
};
}
);
server.registerTool(
'cancel_order',
{
title: 'Cancel order',
description: 'Cancel an order that has not shipped yet. Cannot be undone.',
inputSchema: z.object({ orderId: z.string().describe('Order ID to cancel') }),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
},
async ({ orderId }) => {
const order = orders.get(orderId);
if (!order || order.status === 'shipped') {
return { content: [{ type: 'text', text: `Order ${orderId} cannot be cancelled.` }], isError: true };
}
order.status = 'cancelled';
return { content: [{ type: 'text', text: `Cancelled ${orderId}.` }] };
}
);
return server;
}
export const handler = createMcpHandler(createServer);
createMcpHandler wraps the factory as a web-standard HTTP handler. For a desktop host, serve the same factory over stdio in a second file, stdio.ts:
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.ts';
void serveStdio(createServer);
console.error('orders MCP server running on stdio');
Now look at each config field in turn.
title and description
title is for people. Hosts show it in tool lists and approval prompts. description is for the model. It is the main signal the model uses to pick a tool, so say what the tool does, what input looks like and what comes back.
In the run, tools/list returned both fields as written. Our guide to MCP tool names and descriptions covers wording rules.
inputSchema
The Tools guide says one Zod schema gives you three things: the JSON Schema the model sees, argument validation before your handler runs, and inferred handler types.
Here is what the run advertised for get_order_status:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"orderId": {
"type": "string",
"pattern": "^A-\\d{4}$",
"description": "Order ID, format A-1234"
}
},
"required": ["orderId"]
}
The regex became pattern, and .describe() became description. Use constraints like .regex(), .int(), .min(), .max() and .enum() freely. They cost nothing and stop bad calls before your code runs.
A tool with no arguments omits inputSchema. The upgrade guide warns that the handler then receives the request context as its only argument, not an arguments object.
outputSchema and structuredContent
Add outputSchema when the result has a shape a program might use. Return the data twice: as text in content and as structuredContent. The 2026-07-28 tools spec says a tool returning structured content should also return the serialized JSON in a text block, for older clients.
The run returned:
{"content":[{"type":"text","text":"{\"orderId\":\"A-1001\",\"status\":\"shipped\",\"total\":42.5}"}],"structuredContent":{"orderId":"A-1001","status":"shipped","total":42.5}}
The SDK checks your own output too. When a handler returned structuredContent with a string where the schema wanted a number, the call came back as:
{"content":[{"type":"text","text":"Output validation error: Invalid structured content for tool t: total: Invalid input: expected number, received string"}],"isError":true}
That protects clients from a server bug. It also means a schema mismatch shows up as a tool error, so watch for "Output validation error" in logs. More on the spec side in MCP structured tool output.
annotations
Annotations describe behavior so the host can decide what to auto-approve and what to confirm. The four hints are:
| Hint | Meaning | Spec default |
|---|---|---|
readOnlyHint | Does not modify its environment | false |
destructiveHint | May make destructive updates (only meaningful if not read-only) | true |
idempotentHint | Repeating the same call has no extra effect | false |
openWorldHint | Talks to outside systems, such as the web | true |
The defaults come from the 2026-07-28 schema reference. Because the defaults assume the worst, a tool with no annotations looks destructive and open-world. Set them honestly on every tool.
Two cautions. The Tools guide says annotations "never change how the SDK runs the tool." And the spec says clients must treat annotations as untrusted unless they come from trusted servers. They help the approval UI; they are not a security control. Our page on MCP tool annotations goes deeper.
The handler and its three error paths
The handler is async and returns an object with content (a list of text, image, audio, resource link or embedded resource blocks). Errors can reach the model three ways, and all three came back as normal tool results with isError: true in the run:
- Bad input. The SDK rejects it before your handler runs. Calling with
orderId: "oops"returned:Input validation error: Invalid arguments for tool get_order_status: orderId: Invalid string: must match pattern /^A-\d{4}$/. - Returned error. You return
isError: truewith a message, as the not-found branch does. Use this when you know what went wrong and can tell the model how to fix it. - Thrown error. The Errors guide says the SDK catches anything a tool handler throws and converts it to the same
isError: trueshape.
Prefer path 2 for expected failures. The Errors guide's advice is to put the recovery hint in the text, because "it is the only thing the model has to work with." Protocol errors, such as an unknown tool name, are different: they are JSON-RPC errors the model never sees.
Test it
The SDK's testing guide connects a real Client to handler.fetch in-process, with no port. That is how the outputs above were produced. For a manual check over stdio, use the MCP Inspector CLI:
npx @modelcontextprotocol/inspector --cli npx tsx stdio.ts \
--method tools/call --tool-name cancel_order \
--tool-args-json '{"orderId":"A-1002"}' --format json
With Inspector 2.13.0 this returned {"result":{"content":[{"type":"text","text":"Cancelled A-1002."}]}}. Our Inspector lesson shows the web UI as well.
Migrating from v1 server.tool()
The v2 upgrade notes say the variadic .tool(), .prompt() and .resource() methods are removed. The same example, before and after:
// v1: raw shape, variadic
server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => {
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
});
// v2: config object, Standard Schema
server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => {
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
});
The official codemod does most of the work:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
grep -rn '@mcp-codemod-error' .
Per the upgrade skill, it rewrites .tool() to registerTool, wraps raw Zod shapes in z.object(), renames McpError to ProtocolError, and marks anything it could not rewrite with @mcp-codemod-error. Raw shapes are still accepted through deprecated overloads, but wrap them yourself.
| v1 | v2 |
|---|---|
@modelcontextprotocol/sdk | @modelcontextprotocol/server (and /client) |
server.tool(name, desc, shape, cb) | server.registerTool(name, { description, inputSchema }, cb) |
Raw shape { x: z.string() } | z.object({ x: z.string() }) |
McpError | ProtocolError |
| Zod 3 or 4 | Zod 4 only (4.2.0+ recommended) |
No-argument handler gets extra | No-argument handler gets ctx |
Pitfalls
- Old tutorials. Anything importing
@modelcontextprotocol/sdk/server/mcp.jsor callingserver.tool()is v1. - Zod 3 in
package.json. The upgrade guide says it can install cleanly and then fail at the firsttools/list. - Missing annotations. Defaults mark the tool destructive, so hosts may ask for approval on every call.
- Text-only results for data. Add
outputSchemawhen a program, not just a person, will read the result. console.logon stdio. It corrupts the stream. Useconsole.error.- Huge argument arrays.
new McpServer(info, { maxToolInputElements: 10_000 })caps array and object sizes per call; it is off by default.
Next step
Once your tools are solid, deploy them as a remote server: Lesson 21: deploy a remote server continues the MCP course. If you want a production MCP server designed, built and hardened for your product, see our MCP development service.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
What replaced server.tool() in the MCP TypeScript SDK?
server.registerTool(name, config, handler) in v2. The config object holds description, inputSchema and the other fields. The official codemod converts v1 calls.
Do I need an outputSchema?
No. It is optional. Add it when the result has a fixed shape that another program will read, and return the data as both text and structuredContent.
Can I use a JSON Schema instead of Zod?
The v2 SDK expects Standard Schema objects, so Zod 4, Valibot and ArkType work. The upgrade guide also documents fromJsonSchema() for registering a raw JSON Schema.
Do annotations make a tool safe?
No. They are hints for the host's approval UI. The spec says clients must treat them as untrusted unless the server is trusted, and the SDK does not enforce them.
Should my tool throw or return isError?
Both reach the model as isError: true. Return it yourself for expected failures so you control the message, and let unexpected exceptions throw.











