How to Create Custom Tools in MCP Servers Using the TypeScript SDK

To create custom tools in MCP servers using the TypeScript SDK, define a Zod schema for input validation, create a metadata configuration object with name and description, implement an async handler that returns a CallToolResult, and register the tool with the McpServer instance via server.registerTool().

The Model Context Protocol (MCP) enables servers to expose functionality to clients through declarative, schema-validated tools. When you create custom tools in MCP servers using the TypeScript SDK, you follow the modular registration pattern established in the modelcontextprotocol/servers repository. This guide demonstrates the exact implementation used by built-in tools, covering schema definition, handler implementation, and server integration.

Define the Tool's Input Schema with Zod

Every custom tool requires strict input validation to ensure type safety. In src/everything/tools/echo.ts, the EchoSchema uses Zod to describe expected arguments:

import { z } from "zod";

export const EchoSchema = z.object({
  message: z.string().describe("Message to echo"),
});

The schema automatically validates incoming arguments against defined types before your handler executes, preventing invalid data from reaching your business logic.

Describe the Tool with Metadata

Create a configuration object containing the tool's name, title, description, and inputSchema. In the modelcontextprotocol/servers codebase, this metadata object accompanies the schema definition in the same file:

const name = "echo";
const config = {
  title: "Echo Tool",
  description: "Echoes back the input string",
  inputSchema: EchoSchema,
};

This metadata registers with the MCP server and becomes visible to clients during tool discovery.

Implement the Handler Function

The handler receives raw arguments, validates them against your Zod schema using .parse(), and returns a CallToolResult. The registerEchoTool function in src/everything/tools/echo.ts demonstrates this pattern:

export const registerEchoTool = (server: McpServer) => {
  server.registerTool(name, config, async (args): Promise<CallToolResult> => {
    const validated = EchoSchema.parse(args);
    return {
      content: [{ type: "text", text: `Echo: ${validated.message}` }],
    };
  });
};

The handler must return an object with a content array containing typed content blocks (text, images, or resources).

Register the Tool with the Server

Export a registration function that accepts an McpServer instance and calls server.registerTool(). Centralize all tool registrations in src/everything/tools/index.ts to maintain clean separation of concerns:

import { registerEchoTool } from "./echo.js";

export const registerTools = (server: McpServer) => {
  // Existing tools...
  registerEchoTool(server);
};

This centralization pattern allows the server bootstrap code to import a single registerTools function that wires up all available functionality.

Wire Registration into Server Startup

The server entry point in src/everything/server/index.ts initializes the McpServer and invokes your registration functions during the startup sequence:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerTools, registerConditionalTools } from "./tools/index.js";

export const start = async () => {
  const server = new McpServer();
  registerTools(server);
  // Conditional tools are added after client capability negotiation
  server.on("clientReady", () => registerConditionalTools(server));
  await server.listen();
};

Tools registered via registerTools() become immediately available to clients, while conditional tools wait for the clientReady event.

Complete Working Example: Sum Calculator

Below is a minimal end-to-end implementation adding a get-sum tool that adds two numbers.

Create the Tool Implementation

Create src/everything/tools/get-sum.ts with the following content:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

export const SumSchema = z.object({
  a: z.number().describe("First operand"),
  b: z.number().describe("Second operand"),
});

const name = "get-sum";
const config = {
  title: "Sum Numbers",
  description: "Returns the sum of two numbers",
  inputSchema: SumSchema,
};

export const registerGetSumTool = (server: McpServer) => {
  server.registerTool(name, config, async (args): Promise<CallToolResult> => {
    const { a, b } = SumSchema.parse(args);
    return { content: [{ type: "text", text: `Result: ${a + b}` }] };
  });
};

Register the New Tool

Update src/everything/tools/index.ts to include the new registration:

import { registerGetSumTool } from "./get-sum.js";

export const registerTools = (server: McpServer) => {
  // ...other tools
  registerGetSumTool(server);
};

Invoke from a Client

Once the server is running, clients call the tool by name with validated arguments:

{
  "tool": "get-sum",
  "arguments": { "a": 3, "b": 7 }
}

The server responds with:

{
  "content": [{ "type": "text", "text": "Result: 10" }]
}

Summary

  • Input Validation: Define Zod schemas in your tool file to enforce type safety and validate client inputs before execution.
  • Metadata Configuration: Create a config object with name, title, description, and inputSchema for tool discovery and documentation.
  • Handler Implementation: Write async functions that parse validated arguments using Schema.parse() and return CallToolResult objects containing content arrays.
  • Registration Pattern: Export register*Tool functions that call server.registerTool(), then import these into src/everything/tools/index.ts for centralized management.
  • Server Integration: Wire tool registration into the server startup flow in src/everything/server/index.ts to make tools available immediately or conditionally after client capability negotiation.

Frequently Asked Questions

What is the purpose of the Zod schema in MCP tool creation?

The Zod schema enforces runtime type validation for tool inputs. When you create custom tools in MCP servers using the TypeScript SDK, the schema automatically validates incoming arguments against defined types before your handler executes, ensuring that only properly formatted data reaches your business logic.

How does the server.registerTool() method work?

The server.registerTool() method, as implemented in the MCP SDK, accepts three parameters: a string name, a configuration object containing metadata and the input schema, and an async handler function. This method binds the tool to the McpServer instance and makes it discoverable and callable by MCP clients through the standardized protocol.

Can tools be registered conditionally based on client capabilities?

Yes. The modelcontextprotocol/servers repository demonstrates conditional registration in src/everything/server/index.ts using the clientReady event. Tools that require specific client capabilities should be registered inside the server.on("clientReady", () => registerConditionalTools(server)) callback rather than in the initial registerTools() call, as seen in the alternative registration pattern used in src/sequentialthinking/lib.ts.

Where should custom tool files be located in the project structure?

Custom tool implementations belong in the src/everything/tools/ directory, with each tool typically residing in its own file (e.g., get-sum.ts). Registration functions are then centralized in src/everything/tools/index.ts, following the pattern established by built-in tools like echo and demonstrated in the reference implementation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →