Introduction

Tools are the most common capability in MCP servers. They let an LLM perform actions: call APIs, query databases, process files, or run computations. In the TypeScript SDK v2, you define tools using server.registerTool() with a Zod-based input schema and an async handler function.

Key Concepts

  • server.registerTool(): The method to register a tool on the server, taking a name, config object, and handler function.
  • inputSchema: A Zod schema that validates the tool's input parameters. The SDK automatically validates inputs before calling your handler.
  • outputSchema: An optional Zod schema that defines the structured output shape for typed results.
  • CallToolResult: The return type of a tool handler, containing a content array with text, image, or resource items.
  • structuredContent: An optional field alongside content that provides machine-readable structured data matching the outputSchema.

Real World Context

Consider a team building an internal MCP server for their customer support system. They might define tools like lookup-customer, create-ticket, and check-order-status. Each tool has clearly defined inputs (customer ID, ticket details) and outputs (customer data, ticket confirmation). This lets support agents use Claude to interact with their systems through natural language.

Deep Dive

The server.registerTool() method takes three arguments: the tool name, a configuration object, and an async handler function.

Here is a complete example that defines a BMI calculator tool with both input and output schemas:

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

const server = new McpServer({ name: 'health-tools', version: '1.0.0' });

server.registerTool(
  'calculate-bmi',
  {
    title: 'BMI Calculator',
    description: 'Calculate Body Mass Index',
    inputSchema: z.object({
      weightKg: z.number().describe('Weight in kilograms'),
      heightM: z.number().describe('Height in meters')
    }),
    outputSchema: z.object({ bmi: z.number() })
  },
  async ({ weightKg, heightM }): Promise<CallToolResult> => {
    const bmi = weightKg / (heightM * heightM);
    return {
      content: [{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }],
      structuredContent: { bmi }
    };
  }
);

The registerTool call breaks down into three parts. The first argument 'calculate-bmi' is the tool name that clients use to invoke it. The second argument is the config object with title, description, inputSchema, and optionally outputSchema. The third argument is the async handler that receives the validated input and returns a CallToolResult.

The inputSchema uses Zod to define and validate parameters. The .describe() calls on each field provide documentation that LLMs use to understand what values to pass. When a client calls the tool, the SDK validates the input against this schema before your handler runs.

The handler returns a CallToolResult with a content array. Each content item has a type field. The most common type is 'text' for plain text responses.

Here is a simpler tool that only returns text content without a structured output:

typescript
server.registerTool(
  'greet',
  {
    description: 'Generate a greeting message',
    inputSchema: z.object({
      name: z.string().describe('Name to greet')
    })
  },
  async ({ name }): Promise<CallToolResult> => {
    return {
      content: [{ type: 'text', text: `Hello, ${name}! Welcome aboard.` }]
    };
  }
);

This tool does not define an outputSchema because it only returns human-readable text. The title field is also optional and defaults to the tool name if omitted.

When you define an outputSchema, you must also provide structuredContent in the result. This gives clients both a human-readable text representation and a machine-parseable structured result.

Common Pitfalls

  • Forgetting .describe() on schema fields: Without descriptions, the LLM has to guess what each parameter means based only on the field name.
  • Returning structuredContent without outputSchema: If you include structuredContent in the result, you must also define outputSchema in the config so clients know the shape.
  • Using the wrong Zod import: The SDK uses import { z } from 'zod'. Using a namespace import or wrong path may cause issues.

Best Practices

  • Always provide a clear description for every tool so LLMs understand when to use it.
  • Use .describe() on every Zod field to help LLMs provide correct values.
  • Keep tool names lowercase with hyphens (e.g., 'calculate-bmi') for consistency.
  • Return both content and structuredContent when you have typed outputs so both humans and machines can consume the result.

Summary

You learned how to define MCP tools using server.registerTool() with Zod-based input validation, optional output schemas, and async handlers that return CallToolResult objects. Tools are the primary way to expose actionable capabilities from your MCP server.

Code Examples

typescript
server.registerTool(
  'calculate-bmi',
  {
    title: 'BMI Calculator',
    description: 'Calculate Body Mass Index',
    inputSchema: z.object({
      weightKg: z.number().describe('Weight in kilograms'),
      heightM: z.number().describe('Height in meters')
    }),
    outputSchema: z.object({ bmi: z.number() })
  },
  async ({ weightKg, heightM }): Promise<CallToolResult> => {
    const bmi = weightKg / (heightM * heightM);
    return {
      content: [{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }],
      structuredContent: { bmi }
    };
  }
);
✓ Completed