Menu

AI

registerTool in the TypeScript MCP SDK v2: Every Option Explained

A deep dive on registerTool in @modelcontextprotocol/server v2. Title, description, Zod input and output schemas, annotations, isError and v1 tool() migration.

By: Irina ShvayaNovember 2, 2026

registerTool in the TypeScript MCP SDK v2: Every Option Explained

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.
  • inputSchema is a Zod object: the SDK turns it into the advertised JSON Schema, validates arguments and types your handler.
  • outputSchema plus structuredContent gives clients typed results; the SDK checks the result before it leaves the server.
  • annotations are hints for the host's approval UI and never change how the SDK runs the tool.
  • Bad input, a returned isError: true and a thrown error all reach the model as readable tool results.
  • v1 server.tool() is removed in v2; the official codemod rewrites it to registerTool.

The signature at a glance

server.registerTool(name, config, handler);
PartFieldWhat it does
namestringThe ID the model calls. Keep it short and stable.
configtitleDisplay name for people in the host UI.
configdescriptionWhat the model reads to decide when to call the tool.
configinputSchemaZod object for arguments. Omit it for tools with no arguments.
configoutputSchemaZod object for structuredContent. Optional.
configannotationsHints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint.
handlerasync functionGets 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:

HintMeaningSpec default
readOnlyHintDoes not modify its environmentfalse
destructiveHintMay make destructive updates (only meaningful if not read-only)true
idempotentHintRepeating the same call has no extra effectfalse
openWorldHintTalks to outside systems, such as the webtrue

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:

  1. 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}$/.
  2. Returned error. You return isError: true with 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.
  3. Thrown error. The Errors guide says the SDK catches anything a tool handler throws and converts it to the same isError: true shape.

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.

v1v2
@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() })
McpErrorProtocolError
Zod 3 or 4Zod 4 only (4.2.0+ recommended)
No-argument handler gets extraNo-argument handler gets ctx

Pitfalls

  • Old tutorials. Anything importing @modelcontextprotocol/sdk/server/mcp.js or calling server.tool() is v1.
  • Zod 3 in package.json. The upgrade guide says it can install cleanly and then fail at the first tools/list.
  • Missing annotations. Defaults mark the tool destructive, so hosts may ask for approval on every call.
  • Text-only results for data. Add outputSchema when a program, not just a person, will read the result.
  • console.log on stdio. It corrupts the stream. Use console.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.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.