Adding custom tools to an MCP server in TypeScript
Kashish Hora
Co-founder of AgentCat
The quick answer
You register a tool on an McpServer with registerTool. You give it a name, a small config object (title, description, and a Zod inputSchema), and an async handler that returns content.
import { z } from "zod";
server.registerTool(
"get_weather",
{
title: "Weather lookup",
description: "Get the current weather for a city.",
inputSchema: {
city: z.string().describe("City name, e.g. \"Berlin\""),
unit: z.enum(["celsius", "fahrenheit"]).default("celsius"),
},
},
async ({ city, unit }) => {
const report = await fetchWeather(city, unit);
return { content: [{ type: "text", text: report }] };
}
);registerTool is the current API. If you have older code calling server.tool(...), that method is deprecated and is removed in the 2.x line, so this is the one to build on (SDK docs).
The rest of this guide is about writing tools that a model actually uses correctly: describing them well, validating inputs, returning structured results, and failing in a way the model can reason about. It assumes you already have a server running. If you don't, start with building an MCP server in TypeScript for project setup, transport, and connecting to a client, then come back here.
A version note before you dive in: this guide's code targets the @modelcontextprotocol/sdk 1.x line, which is still npm's latest tag and fully supported. The 2026-07-28 spec revision ships a parallel 2.0 SDK line (@modelcontextprotocol/server and friends, split out of the old monolith), which keeps registerTool but expects a wrapped schema — inputSchema: z.object({ ... }) rather than the raw shape used throughout this guide; migrate to it with npx @modelcontextprotocol/codemod@latest v1-to-v2 . when you're ready to move off 1.x.
Before you start
You need a working McpServer instance and Zod. Both come in through the setup guide, but the two imports that matter here are:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";Two things trip people up. First, the SDK ships as ES modules with NodeNext resolution, so import paths carry the .js extension even in TypeScript source: it's .../server/mcp.js, not .../server/mcp (SDK docs). Second, on this 1.x monolith there's no separate tools package: everything lives in @modelcontextprotocol/sdk, so a .../sdk/testing subpath still doesn't exist. An import from @modelcontextprotocol/server is different: as of the 2026-07-28 spec revision that's a real package, part of the new split SDK line (@modelcontextprotocol/server//client//core etc., 2.0.0) — just not the one this guide uses.
The three parts of a tool
Every tool is a name, a config object, and a handler. The name is the stable identifier the client calls (snake_case reads well and is the common convention). The config carries the human- and model-facing metadata plus the input schema. The handler runs when the tool is called and returns a result.
server.registerTool(
"tool_name",
{
title: "Human-readable name",
description: "What it does and when to use it.",
inputSchema: {
// Zod shape: each key is one argument
},
},
async (args) => {
return { content: [{ type: "text", text: "result" }] };
}
);The keys in inputSchema become the tool's parameters, and because it's Zod, your handler's args are fully typed with no extra work: TypeScript infers args straight from the shape.
Write descriptions the model will act on
The single highest-leverage thing you can do for a tool is describe it well, because the description and parameter docs are what the model reads when deciding whether and how to call it. title is a display label for humans; description is the working instructions for the model. Both are part of the tool definition the client receives (spec).
Say what the tool does, when to reach for it, and anything non-obvious about the arguments. Put per-argument guidance in .describe() so it travels with the schema:
server.registerTool(
"search_orders",
{
title: "Search orders",
description:
"Find orders by customer email or order ID. Returns at most `limit` results, newest first. Use this before refunding so you have the exact order ID.",
inputSchema: {
email: z.string().email().optional().describe("Customer email to match"),
orderId: z.string().optional().describe("Exact order ID, e.g. \"ord_123\""),
limit: z.number().int().min(1).max(50).default(10)
.describe("How many results to return (1-50)"),
},
},
async ({ email, orderId, limit }) => {
const orders = await findOrders({ email, orderId, limit });
return { content: [{ type: "text", text: JSON.stringify(orders) }] };
}
);A vague description: "Searches orders" and undocumented parameters are how you end up with a model that calls the tool with the wrong argument or doesn't call it when it should.
Validate inputs with Zod
The inputSchema isn't just for type inference. The SDK validates incoming arguments against it before your handler runs, so invalid calls never reach your code. You get to express real constraints, defaults, and nested shapes:
inputSchema: {
query: z.string().min(3).max(100).describe("Search text"),
filters: z.object({
category: z.enum(["posts", "users", "comments"]),
since: z.string().datetime().optional(),
}).optional(),
limit: z.number().int().positive().max(50).default(10),
}When the arguments don't satisfy the schema, the SDK rejects the call before your handler runs and returns an isError result whose text contains Input validation error: Invalid arguments for tool <name>: ... (SDK source). That's the behavior you want: the model gets a precise, actionable message instead of your code throwing on a missing field.
One caveat worth knowing: the current SDK generates the client-facing JSON Schema from your Zod shape, and not every exotic Zod construct maps cleanly. Stick to the common validators (strings, numbers, enums, objects, arrays, .optional(), .default(), .describe()) for your public parameters and you'll stay on well-supported ground.
Return structured output when the result has a shape
Text content is fine for prose, but when a tool returns data (a record, a list, a computed object) you can declare an outputSchema and return structuredContent. The model then gets a typed object it can consume directly instead of re-parsing a string, and clients can validate the result (spec).
server.registerTool(
"get_bmi",
{
title: "BMI calculator",
description: "Compute Body Mass Index from weight and height.",
inputSchema: {
weightKg: z.number().positive(),
heightM: z.number().positive(),
},
outputSchema: {
bmi: z.number(),
category: z.enum(["underweight", "normal", "overweight", "obese"]),
},
},
async ({ weightKg, heightM }) => {
const bmi = weightKg / (heightM * heightM);
const output = { bmi, category: categorize(bmi) };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);Two rules make this reliable. First, once you declare an outputSchema, you have to return structuredContent that satisfies it: if you return only content, the SDK returns an isError result whose text contains Output validation error: Tool <name> has an output schema but no structured content was provided (SDK source). Second, keep returning the serialized JSON in a text block alongside structuredContent for clients that don't read structured results (spec). The example above does both.
Tools can also return images and audio (base64 with a mimeType) or embedded resources in the content array. The SDK docs cover those content types.
Failing: return isError, or throw
MCP has two ways a tool call can fail, and the error-handling guide covers the distinction in full (spec).
A protocol error is a JSON-RPC-level problem, like a call to a method that doesn't exist. You rarely construct these by hand; the SDK and transport handle them.
A tool execution error is a call that reached your tool but couldn't finish: an upstream API was down, a record wasn't found, a business rule said no. You report it inside a normal result with isError: true, and the model reads the message and can adjust or retry rather than the whole call blowing up. With registerTool this is where almost everything lands: the SDK wraps a thrown exception, and even a failed input-validation check, into an isError result for you (that's the Input validation error: ... from earlier).
async ({ customerId }) => {
const customer = await getCustomer(customerId);
if (!customer) {
return {
content: [{ type: "text", text: `No customer found for id ${customerId}.` }],
isError: true,
};
}
// ... proceed
}There's a convenience worth understanding: if your handler throws, McpServer catches it and turns it into an isError result whose text is the error message, rather than tearing down the connection (SDK source). So you can lean on plain throw for unexpected failures and reserve explicit isError returns for the expected ones where you want to control the message the model reads. Either way, log the real error server-side and keep the model-facing text clean and specific. For retries, timeouts, and error-shaping across a whole server, see error handling in custom MCP servers.
Tell clients how the tool behaves with annotations
Annotations are optional hints about what a tool does to the world, so a client can decide, for example, whether to auto-run it or ask for confirmation. The fields are readOnlyHint, destructiveHint, idempotentHint, and openWorldHint, plus an optional title (SDK source).
server.registerTool(
"delete_file",
{
title: "Delete file",
description: "Permanently delete a file by path.",
inputSchema: { path: z.string() },
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ path }) => {
await fs.rm(path);
return { content: [{ type: "text", text: `Deleted ${path}` }] };
}
);The defaults assume the riskier case, so annotate deliberately: readOnlyHint defaults to false, destructiveHint to true, idempotentHint to false, and openWorldHint to true (SDK source). Mark a pure lookup readOnlyHint: true so clients know it's safe to run without a prompt. And treat annotations as advisory, not enforcement: the spec is explicit that clients must consider them untrusted unless the server is trusted, so they guide UI, they don't replace your own access checks (spec).
Registering several tools cleanly
Real servers expose more than one tool, and the config object grows repetitive fast. Keep each tool's schema and handler close together, and register them from one place so the shape of your server is easy to read:
export function registerOrderTools(server: McpServer) {
server.registerTool("search_orders", searchOrdersConfig, searchOrdersHandler);
server.registerTool("get_order", getOrderConfig, getOrderHandler);
server.registerTool("refund_order", refundOrderConfig, refundOrderHandler);
}Two things help as this grows. Shared Zod schemas keep related tools consistent: define an OrderId schema once and reuse it across get_order and refund_order so the parameter docs and validation stay in sync. And registerTool returns a RegisteredTool handle with enable(), disable(), remove(), and update(), so you can toggle a tool at runtime (say, gate an admin tool behind a flag); on the 1.x/legacy line, the SDK notifies connected clients of the change automatically (SDK docs). Under 2026-07-28 there are no persistent sessions: notifications/tools/list_changed reaches only clients holding an open subscriptions/listen stream with toolsListChanged: true, and the tool set must not vary per-connection — vary it by the authorization on the request instead.
Next steps
You now have the pieces for tools a model uses well: clear descriptions, validated inputs, structured output where it fits, sensible failure modes, and honest behavior annotations. From here, dig into error handling in custom MCP servers for resilience patterns, and validation and tests for tool inputs to lock the behavior in before you ship.
Related Guides
Building an MCP server in TypeScript
Build an MCP server with the TypeScript SDK: the @modelcontextprotocol/sdk 1.x McpServer API pinned to 1.30.0, registerTool with structured output, and both transports (stdio and Streamable HTTP), plus a note on the newer split @modelcontextprotocol/server 2.0 line for the 2026-07-28 spec revision.
Error handling in custom MCP servers
The one distinction that governs MCP error handling: protocol errors are JSON-RPC errors that fail the request, while tool failures are ordinary results with isError set true so the model can see and recover from them.
Validation tests for tool inputs
Write validation tests for MCP tool inputs covering schema validation and type checking.