P230 min

Adding Tools

Add tools with complex inputs, HTTP requests, error handling, and input validation to your server.

On This Page

Key Concepts

  • Multiple parameters with z.object()
  • Optional fields with .optional()
  • Enum constraints with z.enum()
  • Fetching external data from tools
  • Returning image content (base64)
  • isError flag for tool failures
  • SDK-level input validation

Where We Left Off

In the previous module, you built a server with a single echo tool. Now you will add tools that are more realistic: tools with multiple parameters, tools that fetch data from the internet, tools that return images, and tools that handle errors properly.

Open your my-first-mcp-server project from the last module. All the new code goes into src/index.ts, after your existing echo tool and before the transport/connect lines at the bottom.


Tool with Multiple Parameters

Real tools need more than one input. Let us build a calculate tool that takes two numbers and an operation. Add this after your echo tool:

server.tool(
  "calculate",
  "Performs basic arithmetic on two numbers.",
  {
    a: z.number().describe("The first number"),
    b: z.number().describe("The second number"),
    operation: z
      .enum(["add", "subtract", "multiply", "divide"])
      .describe("The arithmetic operation to perform"),
  },
  async ({ a, b, operation }) => {
    let result: number;

    switch (operation) {
      case "add":
        result = a + b;
        break;
      case "subtract":
        result = a - b;
        break;
      case "multiply":
        result = a * b;
        break;
      case "divide":
        if (b === 0) {
          return {
            content: [{ type: "text", text: "Error: Division by zero" }],
            isError: true,
          };
        }
        result = a / b;
        break;
    }

    return {
      content: [
        {
          type: "text",
          text: `${a} ${operation} ${b} = ${result}`,
        },
      ],
    };
  }
);

Optional Parameters

Not every parameter should be required. Zod makes it easy to define optional fields with default values. Add this tool:

server.tool(
  "format_date",
  "Formats a date string into a human-readable format.",
  {
    date: z
      .string()
      .describe("The date to format, e.g. '2025-01-15' or 'January 15, 2025'"),
    locale: z
      .string()
      .optional()
      .describe("Locale for formatting, e.g. 'en-US', 'fr-FR'. Defaults to 'en-US'."),
    style: z
      .enum(["short", "medium", "long", "full"])
      .optional()
      .describe("Date formatting style. Defaults to 'long'."),
  },
  async ({ date, locale, style }) => {
    const parsedDate = new Date(date);

    if (isNaN(parsedDate.getTime())) {
      return {
        content: [{ type: "text", text: `Error: Could not parse "${date}" as a date.` }],
        isError: true,
      };
    }

    const formatted = parsedDate.toLocaleDateString(locale ?? "en-US", {
      dateStyle: style ?? "long",
    });

    return {
      content: [{ type: "text", text: formatted }],
    };
  }
);

What Just Happened?

You used several new Zod features in these two tools:

  • z.number() — Defines a numeric parameter. The SDK validates that the client sends a number, not a string. If the AI sends "a": "five", the SDK rejects it before your handler runs.
  • z.enum([...]) — Restricts a string to specific allowed values. The AI model sees these options in the tool schema and will only pick from the list. If it sends an invalid value, the SDK rejects it.
  • .optional() — Makes a parameter optional. The client can omit it entirely. In your handler, the value will be undefined if not provided, so you use the ?? operator to supply defaults.
  • .describe() — Adds a description the AI model reads to understand what value to provide. Always include descriptions for every parameter. The AI is reading your schema like documentation.

Design tip: Think of your tool schema as a user interface for the AI model. The tool name is the button label, the description is the tooltip, and each parameter description is a form field label. Make them clear and specific enough that the AI always knows what to send.


Tool That Makes HTTP Requests

Many real MCP tools fetch data from external services. Let us build a tool that fetches a random fact from a public API. No API key required.

server.tool(
  "random_fact",
  "Fetches a random interesting fact. Call this when the user wants to learn something new or needs a fun fact.",
  // Empty schema — this tool takes no parameters
  {},
  async () => {
    const response = await fetch(
      "https://uselessfacts.jsph.pl/api/v2/facts/random?language=en"
    );

    if (!response.ok) {
      return {
        content: [
          {
            type: "text",
            text: `Failed to fetch a fact. API returned status ${response.status}.`,
          },
        ],
        isError: true,
      };
    }

    const data = await response.json();

    return {
      content: [
        {
          type: "text",
          text: data.text,
        },
      ],
    };
  }
);

What Just Happened?

  • Empty schema {} — Tools with no parameters pass an empty object. The AI model sees this in the schema and knows it can call the tool without providing any arguments.
  • fetch() — Node.js 18+ has built-in fetch. You do not need to install node-fetch or axios.
  • Error checking — Always check response.ok before parsing the body. Network requests fail, APIs go down, rate limits get hit. Your tool should handle these gracefully, not crash the server.
  • isError: true — When a tool fails, include isError: true in the response. This tells the AI model that the tool call failed, so it can report the error to the user or try a different approach. Without this flag, the AI treats error messages as successful results.

Returning Images

MCP tools can return images as base64-encoded content. This is useful for screenshots, charts, generated graphics, or any visual data. Here is a tool that fetches a placeholder image:

server.tool(
  "placeholder_image",
  "Generates a placeholder image with custom dimensions and text. Useful for mockups and testing.",
  {
    width: z.number().min(1).max(2000).describe("Image width in pixels"),
    height: z.number().min(1).max(2000).describe("Image height in pixels"),
    text: z
      .string()
      .optional()
      .describe("Text to display on the image. Defaults to the dimensions."),
  },
  async ({ width, height, text }) => {
    const label = text ?? `${width}x${height}`;
    const url = `https://placehold.co/${width}x${height}/png?text=${encodeURIComponent(label)}`;

    const response = await fetch(url);

    if (!response.ok) {
      return {
        content: [
          { type: "text", text: `Failed to generate image: HTTP ${response.status}` },
        ],
        isError: true,
      };
    }

    // Read the response as a buffer and convert to base64
    const buffer = await response.arrayBuffer();
    const base64 = Buffer.from(buffer).toString("base64");

    return {
      content: [
        {
          type: "image",
          data: base64,
          mimeType: "image/png",
        },
      ],
    };
  }
);

Key points about image content:

  • type: "image" — Tells the client this content item is an image, not text.
  • data — The raw image data encoded as a base64 string. You convert the binary response to base64 using Buffer.from(buffer).toString("base64").
  • mimeType — The image format. Common values are image/png, image/jpeg, and image/gif. The client uses this to display the image correctly.
  • z.number().min(1).max(2000) — Zod supports chained validations. Here we ensure the dimensions are positive and not absurdly large. The AI model sees these constraints in the schema.

Mixed content: You can return both text and images in the same response by including multiple items in the content array:

return {
  content: [
    { type: "text", text: "Here is your placeholder image:" },
    { type: "image", data: base64, mimeType: "image/png" },
  ],
};

Error Handling

You have already seen isError: true in the examples above. Let us formalize the pattern. There are two kinds of errors in MCP tools:

1. Tool-Level Errors (Your Handler Catches Them)

These are expected errors: invalid input, resource not found, API failure. Return a normal response with isError: true:

// GOOD: Tool handles the error and reports it
async ({ url }) => {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      return {
        content: [{ type: "text", text: `HTTP error: ${response.status}` }],
        isError: true,
      };
    }
    const data = await response.json();
    return {
      content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
    };
  } catch (error) {
    return {
      content: [
        {
          type: "text",
          text: `Failed to fetch URL: ${error instanceof Error ? error.message : "Unknown error"}`,
        },
      ],
      isError: true,
    };
  }
}

2. Server-Level Errors (Unhandled Exceptions)

If your handler throws an unhandled exception, the SDK catches it and returns a JSON-RPC error response. The server does not crash, but the error message may be less helpful than one you write yourself.

// BAD: Unhandled exception — the SDK catches it but the error message is generic
async ({ url }) => {
  const response = await fetch(url);  // If this throws, the AI sees a raw error message
  const data = await response.json();
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
}

What Just Happened?

The key insight: always wrap tool logic in try/catch and return structured error messages with isError: true. The AI model reads your error messages and uses them to decide what to do next. A message like "HTTP 404: User not found" is far more useful than "Error: fetch failed".


Input Validation

The MCP SDK validates inputs against your Zod schema before your handler function runs. This means:

  • If the schema says z.number() and the client sends a string, the SDK rejects the request with a validation error. Your handler never executes.
  • If a required field is missing, the SDK rejects the request.
  • If an z.enum() receives a value not in the list, the SDK rejects the request.

You can test this by sending an invalid request through the Inspector. Try calling the calculate tool with operation set to "modulo" — the SDK will reject it because it is not in the enum list.

This means your handler code can trust that the inputs are valid and correctly typed. You do not need to check if a is a number or if operation is one of the allowed values. The schema guarantees it.

When to add extra validation: Zod handles type and format validation. But you still need to validate business logic in your handler — like checking for division by zero, verifying a resource exists, or ensuring a date is in the future.


Try It Yourself

Add a tool called read_file that reads a local file and returns its contents. Here is the specification:

  • Tool name: read_file
  • Description: "Reads a file from the local file system and returns its contents as text."
  • Input: path (string, required) — the file path to read
  • Behavior: Use fs.readFile from node:fs/promises to read the file. Return the contents as text. If the file does not exist, return an error with isError: true.

Hints:

  1. Add import fs from "node:fs/promises"; at the top of the file.
  2. Wrap the read call in try/catch.
  3. Check error.code === "ENOENT" to detect missing files specifically.
  4. Rebuild and test with the Inspector. Try reading a file that exists and one that does not.
Stuck? Here is the solution
import fs from "node:fs/promises";

// ... (add this after your other tools)

server.tool(
  "read_file",
  "Reads a file from the local file system and returns its contents as text.",
  {
    path: z.string().describe("The file path to read"),
  },
  async ({ path }) => {
    try {
      const contents = await fs.readFile(path, "utf-8");
      return {
        content: [{ type: "text", text: contents }],
      };
    } catch (error) {
      const message =
        error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT"
          ? `File not found: ${path}`
          : `Failed to read file: ${error instanceof Error ? error.message : "Unknown error"}`;

      return {
        content: [{ type: "text", text: message }],
        isError: true,
      };
    }
  }
);

Troubleshooting

fetch is not defined

You need Node.js 18 or later. Check your version with node --version. The built-in fetch API was added in Node.js 18.

Tool does not appear in Inspector

Rebuild with npm run build after adding new tools. The Inspector runs the compiled JavaScript, not your TypeScript source.

Validation error when calling a tool

The SDK is rejecting your input because it does not match the schema. Check the error message — it tells you which field failed and why. Common issues:

  • Sending a string where a number is expected
  • Missing a required field
  • Sending a value not in the enum list

Image tool returns blank or broken image

Make sure you are converting the response to base64 correctly. The arrayBuffer() method gives you raw bytes, and Buffer.from(buffer).toString("base64") converts them to a base64 string. Also verify the mimeType matches the actual image format.


Full Code Reference

Here is the complete src/index.ts with all tools from this module:

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

const server = new McpServer({
  name: "my-first-server",
  version: "1.0.0",
});

// --- Tool: echo (from previous module) ---
server.tool(
  "echo",
  "Returns the message you send it.",
  { message: z.string().describe("The message to echo back") },
  async ({ message }) => ({
    content: [{ type: "text", text: `Echo: ${message}` }],
  })
);

// --- Tool: calculate ---
server.tool(
  "calculate",
  "Performs basic arithmetic on two numbers.",
  {
    a: z.number().describe("The first number"),
    b: z.number().describe("The second number"),
    operation: z
      .enum(["add", "subtract", "multiply", "divide"])
      .describe("The arithmetic operation to perform"),
  },
  async ({ a, b, operation }) => {
    let result: number;
    switch (operation) {
      case "add":
        result = a + b;
        break;
      case "subtract":
        result = a - b;
        break;
      case "multiply":
        result = a * b;
        break;
      case "divide":
        if (b === 0) {
          return {
            content: [{ type: "text", text: "Error: Division by zero" }],
            isError: true,
          };
        }
        result = a / b;
        break;
    }
    return {
      content: [{ type: "text", text: `${a} ${operation} ${b} = ${result}` }],
    };
  }
);

// --- Tool: format_date ---
server.tool(
  "format_date",
  "Formats a date string into a human-readable format.",
  {
    date: z.string().describe("The date to format, e.g. '2025-01-15'"),
    locale: z.string().optional().describe("Locale for formatting. Defaults to 'en-US'."),
    style: z
      .enum(["short", "medium", "long", "full"])
      .optional()
      .describe("Date formatting style. Defaults to 'long'."),
  },
  async ({ date, locale, style }) => {
    const parsedDate = new Date(date);
    if (isNaN(parsedDate.getTime())) {
      return {
        content: [{ type: "text", text: `Error: Could not parse "${date}" as a date.` }],
        isError: true,
      };
    }
    const formatted = parsedDate.toLocaleDateString(locale ?? "en-US", {
      dateStyle: style ?? "long",
    });
    return { content: [{ type: "text", text: formatted }] };
  }
);

// --- Tool: random_fact ---
server.tool(
  "random_fact",
  "Fetches a random interesting fact.",
  {},
  async () => {
    const response = await fetch(
      "https://uselessfacts.jsph.pl/api/v2/facts/random?language=en"
    );
    if (!response.ok) {
      return {
        content: [{ type: "text", text: `Failed to fetch fact: HTTP ${response.status}` }],
        isError: true,
      };
    }
    const data = await response.json();
    return { content: [{ type: "text", text: data.text }] };
  }
);

// --- Tool: placeholder_image ---
server.tool(
  "placeholder_image",
  "Generates a placeholder image with custom dimensions and text.",
  {
    width: z.number().min(1).max(2000).describe("Image width in pixels"),
    height: z.number().min(1).max(2000).describe("Image height in pixels"),
    text: z.string().optional().describe("Text to display on the image"),
  },
  async ({ width, height, text }) => {
    const label = text ?? `${width}x${height}`;
    const url = `https://placehold.co/${width}x${height}/png?text=${encodeURIComponent(label)}`;
    const response = await fetch(url);
    if (!response.ok) {
      return {
        content: [{ type: "text", text: `Failed to generate image: HTTP ${response.status}` }],
        isError: true,
      };
    }
    const buffer = await response.arrayBuffer();
    const base64 = Buffer.from(buffer).toString("base64");
    return {
      content: [{ type: "image", data: base64, mimeType: "image/png" }],
    };
  }
);

// --- Start the server ---
const transport = new StdioServerTransport();
await server.connect(transport);

What You Accomplished

  • Added tools with multiple required parameters
  • Used optional parameters with default values
  • Constrained input with enums and min/max validators
  • Built a tool that fetches data from an external API
  • Returned image content as base64
  • Implemented proper error handling with isError
  • Understood how the SDK validates inputs before your handler runs

Your server now has six tools covering text, math, dates, HTTP requests, and images. In the next module, you will add resources — a different way to expose data to AI models.