Module 4: MCP Server in TypeScript
Implement Tools with Zod Schemas
Implement Tools with Zod Schemas
Capsule description
Tools is the primitive you'll implement most. If you look at any popular MCP server — Filesystem, GitHub, Slack, Sentry — most of its capabilities are tools. A resource exposes data, a prompt standardizes interactions, but a tool executes actions. Creating files, querying databases, sending notifications, running builds — all of these are tools.
In module 3 you implemented a basic tool with a simple Zod schema. Now you're going to scale up: schemas with complex validations, enums, arrays, nested objects, default values. You're going to implement robust error handling. You're going to see design patterns that make the difference between a tool that "works" and a tool that is reliable.
This capsule is ~60% code. Each concept is demonstrated with complete implementations you can copy, compile, and run.
Complete anatomy of server.tool()
The server.tool() method takes 4 arguments:
server.tool(
name, // string — unique identifier of the tool
description, // string — what the model reads to decide when to use it
schema, // Record<string, ZodType> — parameters with validation
handler // async (args) => ToolResult — the tool's logic
);
The name
// Convention: snake_case, verb + noun
"create_file" // ✅
"search_users" // ✅
"run_migration" // ✅
// Avoid:
"file" // ❌ — read? create? delete?
"doStuff" // ❌ — camelCase isn't an MCP convention
"create-file" // ❌ — kebab-case isn't an MCP convention
The description
The description is instructions for the model. It's what Claude reads to decide whether it should invoke your tool:
// ❌ Useless description — the model doesn't know when to use it
"Processes data"
// ✅ Useful description — the model knows exactly when to use it
"Creates a new file in the project with the specified content. Creates intermediate directories if they don't exist. Returns an error if the file already exists and overwrite is false."
Rule: A good description answers three questions:
- What does it do? — "Creates a new file"
- What does it receive? — "with the specified content"
- What special behavior does it have? — "Returns an error if it already exists"
The schema (Zod)
The schema defines the tool's parameters. Each property is a z.something():
{
// Basic string
name: z.string().describe("The user's name"),
// String with validation
email: z.string().email().describe("The user's valid email"),
// Number with a range
age: z.number().int().min(0).max(150).describe("The user's age"),
// Enum — limited options
role: z.enum(["admin", "user", "viewer"]).describe("The user's role"),
// Boolean with default
active: z.boolean().default(true).describe("Whether the user is active"),
// Optional
nickname: z.string().optional().describe("The user's nickname"),
}
The handler
The handler receives the arguments validated by Zod and returns a ToolResult:
async ({ name, email, role }) => {
// Your logic here...
// Successful return
return {
content: [{
type: "text" as const,
text: "Tool result",
}],
};
// Return with an error
return {
content: [{
type: "text" as const,
text: "Error description",
}],
isError: true,
};
}
Zod schemas: from basic to advanced
Primitive types
z.string() // any string
z.number() // any number
z.boolean() // true or false
z.null() // null
z.undefined() // undefined
String validations
z.string().min(1) // not empty
z.string().max(100) // maximum 100 characters
z.string().email() // email format
z.string().url() // URL format
z.string().uuid() // UUID format
z.string().regex(/^[a-z]+$/) // match regex
z.string().startsWith("prefix") // starts with
z.string().endsWith(".ts") // ends with
Number validations
z.number().int() // integer
z.number().positive() // positive
z.number().nonnegative() // >= 0
z.number().min(1).max(100) // range
z.number().multipleOf(5) // multiple of
Enums
z.enum(["small", "medium", "large"]) // fixed options
z.enum(["read", "write", "admin"]) // permissions
z.enum(["asc", "desc"]).default("asc") // with default
Arrays
z.array(z.string()) // array of strings
z.array(z.number()).min(1) // at least 1 element
z.array(z.string()).max(10) // maximum 10 elements
Nested objects
z.object({
name: z.string(),
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string(),
}),
})
Unions and optionals
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.union([z.string(), z.number()]) // string | number
z.string().default("hello") // default value
The importance of .describe()
Each field should have .describe(). This description becomes the description of the JSON Schema that the model reads:
// ❌ Without describe — the model has to guess
{
q: z.string(),
n: z.number(),
}
// ✅ With describe — the model knows exactly what to send
{
query: z.string().describe("Text to search for in the project's files"),
maxResults: z.number().int().positive().default(10)
.describe("Maximum number of results to return (default: 10)"),
}
Example 1: Basic tool — Calculator
A simple tool to understand the structure:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "calculator-server",
version: "1.0.0",
});
server.tool(
"calculate",
"Executes a mathematical operation between two numbers. Supports addition, subtraction, multiplication, and division.",
{
a: z.number().describe("First operand"),
b: z.number().describe("Second operand"),
operation: z.enum(["add", "subtract", "multiply", "divide"])
.describe("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" as const, text: "Error: division by zero" }],
isError: true,
};
}
result = a / b;
break;
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
operation: `${a} ${operation} ${b}`,
result,
}, null, 2),
}],
};
}
);
Expected output with inputs { a: 10, b: 3, operation: "multiply" }:
{
"operation": "10 multiply 3",
"result": 30
}
Example 2: Tool with complex parameters — Search files
import * as fs from "fs/promises";
import * as path from "path";
server.tool(
"search_files",
"Searches files in a directory by name, extension, or content. Returns the matches with path and size.",
{
directory: z.string().describe("Directory to search in (absolute path)"),
pattern: z.string().optional()
.describe("File name pattern to search for (e.g., 'utils', 'test')"),
extensions: z.array(z.string()).optional()
.describe("File extensions to include (e.g., ['.ts', '.js'])"),
contentSearch: z.string().optional()
.describe("Text to search for inside the files"),
maxResults: z.number().int().positive().default(20)
.describe("Maximum number of results (default: 20)"),
includeHidden: z.boolean().default(false)
.describe("Include hidden files (those starting with '.')"),
},
async ({ directory, pattern, extensions, contentSearch, maxResults, includeHidden }) => {
const results: Array<{
path: string;
size: string;
matchType: string;
lineMatch?: string;
}> = [];
async function searchDir(dir: string): Promise<void> {
if (results.length >= maxResults) return;
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (results.length >= maxResults) break;
if (!includeHidden && entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await searchDir(fullPath);
continue;
}
let matches = false;
let matchType = "";
if (pattern && entry.name.toLowerCase().includes(pattern.toLowerCase())) {
matches = true;
matchType = "name";
}
if (extensions && extensions.some(ext => entry.name.endsWith(ext))) {
matches = true;
matchType = matchType ? `${matchType}+extension` : "extension";
}
if (!pattern && !extensions && !contentSearch) {
matches = true;
matchType = "all";
}
let lineMatch: string | undefined;
if (contentSearch && !entry.name.endsWith(".lock")) {
try {
const content = await fs.readFile(fullPath, "utf-8");
const lines = content.split("\n");
const matchingLine = lines.findIndex(line =>
line.toLowerCase().includes(contentSearch.toLowerCase())
);
if (matchingLine >= 0) {
matches = true;
matchType = matchType ? `${matchType}+content` : "content";
lineMatch = `L${matchingLine + 1}: ${lines[matchingLine].trim()}`;
}
} catch {
// skip binary files
}
}
if (matches) {
const stats = await fs.stat(fullPath);
results.push({
path: fullPath,
size: `${(stats.size / 1024).toFixed(1)} KB`,
matchType,
...(lineMatch && { lineMatch }),
});
}
}
}
try {
await fs.access(directory);
} catch {
return {
content: [{ type: "text" as const, text: `Error: directory '${directory}' does not exist or is not accessible` }],
isError: true,
};
}
await searchDir(directory);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
directory,
totalResults: results.length,
truncated: results.length >= maxResults,
results,
}, null, 2),
}],
};
}
);
What this example demonstrates:
- Schema with optional parameters (
pattern,extensions,contentSearch) - Arrays in the schema (
extensions: z.array(z.string())) - Defaults (
maxResults: 20,includeHidden: false) - Directory validation before the search
- Result limit to avoid enormous responses
- Error handling (binary files, inaccessible directories)
Example 3: Tool with robust error handling — Run command
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const ALLOWED_COMMANDS = ["ls", "cat", "wc", "head", "tail", "grep", "find", "echo", "date"];
server.tool(
"run_command",
"Executes a safe shell command and returns its output. Only allows safe read commands.",
{
command: z.string().describe("Command to execute (only read commands allowed)"),
workingDirectory: z.string().optional()
.describe("Working directory for the execution"),
timeoutMs: z.number().int().positive().default(10000)
.describe("Timeout in milliseconds (default: 10000)"),
},
async ({ command, workingDirectory, timeoutMs }) => {
const baseCommand = command.split(/\s+/)[0];
if (!ALLOWED_COMMANDS.includes(baseCommand)) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
error: `Command '${baseCommand}' not allowed`,
allowedCommands: ALLOWED_COMMANDS,
}, null, 2),
}],
isError: true,
};
}
if (command.includes("&&") || command.includes("||") || command.includes(";") || command.includes("|")) {
return {
content: [{
type: "text" as const,
text: "Error: chaining operators (&&, ||, ;, |) are not allowed for security",
}],
isError: true,
};
}
try {
const { stdout, stderr } = await execAsync(command, {
cwd: workingDirectory,
timeout: timeoutMs,
maxBuffer: 1024 * 1024,
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
command,
exitCode: 0,
stdout: stdout.trim(),
stderr: stderr.trim() || undefined,
}, null, 2),
}],
};
} catch (error: unknown) {
const execError = error as { code?: number; killed?: boolean; stdout?: string; stderr?: string; message?: string };
if (execError.killed) {
return {
content: [{
type: "text" as const,
text: `Error: command exceeded the timeout of ${timeoutMs}ms`,
}],
isError: true,
};
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
error: "Command failed",
exitCode: execError.code,
stderr: execError.stderr?.trim(),
message: execError.message,
}, null, 2),
}],
isError: true,
};
}
}
);
What this example demonstrates:
- Command whitelist — only allows safe commands
- Injection prevention — doesn't allow chaining
- Timeout — kills the process if it takes too long
- Typed error handling — distinguishes timeout from execution errors
- Diagnostic information — returns stdout, stderr, exit code
Example 4: Tool with real side effects — Note management
import * as fs from "fs/promises";
import * as path from "path";
const NOTES_DIR = process.env.NOTES_DIR || path.join(process.cwd(), "notes");
server.tool(
"manage_notes",
"Manages text notes in a directory. Allows creating, reading, listing, and deleting notes.",
{
action: z.enum(["create", "read", "list", "delete"])
.describe("Action to perform"),
title: z.string().optional()
.describe("Note title (required for create, read, delete)"),
content: z.string().optional()
.describe("Note content (required for create)"),
tags: z.array(z.string()).optional()
.describe("Tags to categorize the note (only for create)"),
},
async ({ action, title, content, tags }) => {
await fs.mkdir(NOTES_DIR, { recursive: true });
switch (action) {
case "create": {
if (!title || !content) {
return {
content: [{ type: "text" as const, text: "Error: 'title' and 'content' are required to create a note" }],
isError: true,
};
}
const filename = `${title.toLowerCase().replace(/\s+/g, "-")}.md`;
const filePath = path.join(NOTES_DIR, filename);
const header = `# ${title}\n\n`;
const tagLine = tags?.length ? `**Tags:** ${tags.join(", ")}\n\n` : "";
const dateLine = `**Created:** ${new Date().toISOString()}\n\n---\n\n`;
const fullContent = header + tagLine + dateLine + content;
await fs.writeFile(filePath, fullContent, "utf-8");
return {
content: [{
type: "text" as const,
text: JSON.stringify({
action: "created",
title,
filename,
path: filePath,
tags: tags || [],
size: `${(fullContent.length / 1024).toFixed(1)} KB`,
}, null, 2),
}],
};
}
case "read": {
if (!title) {
return {
content: [{ type: "text" as const, text: "Error: 'title' is required to read a note" }],
isError: true,
};
}
const filename = `${title.toLowerCase().replace(/\s+/g, "-")}.md`;
const filePath = path.join(NOTES_DIR, filename);
try {
const noteContent = await fs.readFile(filePath, "utf-8");
return {
content: [{ type: "text" as const, text: noteContent }],
};
} catch {
return {
content: [{ type: "text" as const, text: `Error: note '${title}' not found` }],
isError: true,
};
}
}
case "list": {
const files = await fs.readdir(NOTES_DIR);
const notes = files.filter(f => f.endsWith(".md"));
const noteDetails = await Promise.all(
notes.map(async (file) => {
const filePath = path.join(NOTES_DIR, file);
const stats = await fs.stat(filePath);
return {
title: file.replace(".md", "").replace(/-/g, " "),
filename: file,
size: `${(stats.size / 1024).toFixed(1)} KB`,
modified: stats.mtime.toISOString(),
};
})
);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
directory: NOTES_DIR,
totalNotes: noteDetails.length,
notes: noteDetails,
}, null, 2),
}],
};
}
case "delete": {
if (!title) {
return {
content: [{ type: "text" as const, text: "Error: 'title' is required to delete a note" }],
isError: true,
};
}
const filename = `${title.toLowerCase().replace(/\s+/g, "-")}.md`;
const filePath = path.join(NOTES_DIR, filename);
try {
await fs.unlink(filePath);
return {
content: [{ type: "text" as const, text: `Note '${title}' deleted successfully` }],
};
} catch {
return {
content: [{ type: "text" as const, text: `Error: note '${title}' not found` }],
isError: true,
};
}
}
}
}
);
What this example demonstrates:
- Tool with multiple actions via an enum
- Conditional validation (title required for read/delete, content required for create)
- Arrays in inputs (tags)
- Real side effects (creates and deletes files)
- Consistent output formatting
Comparison: Zod vs manual validation
So you understand the real value of Zod, compare both approaches:
// ❌ Manual validation — tedious, error-prone
server.tool("create_user", "Creates a user", {}, async (args: any) => {
if (typeof args.name !== "string" || args.name.length === 0) {
return { content: [{ type: "text", text: "name is required" }], isError: true };
}
if (typeof args.email !== "string" || !args.email.includes("@")) {
return { content: [{ type: "text", text: "invalid email" }], isError: true };
}
if (args.role && !["admin", "user"].includes(args.role)) {
return { content: [{ type: "text", text: "invalid role" }], isError: true };
}
const role = args.role || "user";
// ... tool logic
});
// ✅ Validation with Zod — concise, typed, automatic
server.tool(
"create_user",
"Creates a user",
{
name: z.string().min(1).describe("The user's name"),
email: z.string().email().describe("Valid email"),
role: z.enum(["admin", "user"]).default("user").describe("Role"),
},
async ({ name, email, role }) => {
// name, email, role are already validated and typed
// If the inputs don't pass validation, the SDK returns an error automatically
// ... tool logic
}
);
| Aspect | Manual | Zod |
|---|---|---|
| Lines of code | ~15+ for 3 params | 3 lines |
| Typing | any — no types | Inferred automatically |
| JSON Schema | You write it | Generated automatically |
| Validation errors | Manual messages | Messages generated by Zod |
| Maintainability | Fragile | Declarative |
Design patterns for tools
Pattern 1: dryRun for destructive operations
server.tool(
"cleanup_temp_files",
"Deletes temporary files from a directory. Use dryRun to see what would be deleted without executing.",
{
directory: z.string().describe("Directory to clean"),
olderThanDays: z.number().positive().default(7)
.describe("Delete files older than N days"),
dryRun: z.boolean().default(true)
.describe("If true, only shows what would be deleted without executing"),
},
async ({ directory, olderThanDays, dryRun }) => {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
const entries = await fs.readdir(directory, { withFileTypes: true });
const toDelete: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(directory, entry.name);
const stats = await fs.stat(filePath);
if (stats.mtime < cutoffDate) {
toDelete.push(entry.name);
}
}
if (dryRun) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
mode: "dryRun",
wouldDelete: toDelete.length,
files: toDelete,
message: "Use dryRun: false to execute the cleanup",
}, null, 2),
}],
};
}
for (const file of toDelete) {
await fs.unlink(path.join(directory, file));
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
mode: "executed",
deleted: toDelete.length,
files: toDelete,
}, null, 2),
}],
};
}
);
Rule: Every tool that deletes or modifies data irreversibly should have a dryRun mode with a default of true.
Pattern 2: Return structured JSON
// ❌ Plain string — hard for the model to parse
return {
content: [{ type: "text", text: "3 files found in /src" }],
};
// ✅ Structured JSON — the model can reason about the data
return {
content: [{
type: "text" as const,
text: JSON.stringify({
totalFiles: 3,
directory: "/src",
files: [
{ name: "index.ts", size: "2.4 KB" },
{ name: "utils.ts", size: "1.1 KB" },
{ name: "types.ts", size: "0.8 KB" },
],
}, null, 2),
}],
};
Pattern 3: Idempotent tool when possible
server.tool(
"ensure_directory",
"Ensures that a directory exists. If it already exists, does nothing. If it doesn't exist, creates it.",
{
dirPath: z.string().describe("Path of the directory"),
},
async ({ dirPath }) => {
await fs.mkdir(dirPath, { recursive: true });
const stats = await fs.stat(dirPath);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
path: dirPath,
exists: true,
created: stats.birthtimeMs > Date.now() - 1000 ? "just now" : "already existed",
}, null, 2),
}],
};
}
);
An idempotent tool produces the same result no matter how many times it's run.
Error handling: the part you can't ignore
Error structure
There are two types of errors in a tool:
// 1. Expected error — the tool ran but the result isn't successful
return {
content: [{ type: "text" as const, text: "User not found" }],
isError: true, // ← signals to the model that something didn't go right
};
// 2. Unexpected error — something broke
try {
await riskyOperation();
} catch (error) {
return {
content: [{
type: "text" as const,
text: `Internal error: ${error instanceof Error ? error.message : "unknown"}`,
}],
isError: true,
};
}
Complete error handling pattern
server.tool(
"read_json_file",
"Reads and parses a JSON file. Returns the parsed content or a descriptive error.",
{
filePath: z.string().describe("Path of the JSON file to read"),
},
async ({ filePath }) => {
try {
const raw = await fs.readFile(filePath, "utf-8");
try {
const parsed = JSON.parse(raw);
return {
content: [{
type: "text" as const,
text: JSON.stringify(parsed, null, 2),
}],
};
} catch {
return {
content: [{
type: "text" as const,
text: `Error: the file '${filePath}' doesn't contain valid JSON`,
}],
isError: true,
};
}
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
return {
content: [{ type: "text" as const, text: `Error: file '${filePath}' not found` }],
isError: true,
};
}
if (nodeError.code === "EACCES") {
return {
content: [{ type: "text" as const, text: `Error: no permissions to read '${filePath}'` }],
isError: true,
};
}
return {
content: [{
type: "text" as const,
text: `Unexpected error reading '${filePath}': ${nodeError.message}`,
}],
isError: true,
};
}
}
);
Distinguishing ENOENT (doesn't exist) from EACCES (no permissions) gives the model actionable information to suggest a solution to the user.
Troubleshooting
"The model doesn't invoke the tool"
Cause: The tool's description isn't specific enough or doesn't connect with what the user asks for.
Solution:
// ❌ Vague — the model doesn't know when to use it
"Processes files"
// ✅ Specific — the model knows exactly when to use it
"Searches files in a directory by name or extension. Supports recursive search and content filtering."
"The parameters arrive as undefined"
Cause: The field is optional() but your handler doesn't check whether it exists.
Solution:
// ❌ Crashes if title is undefined
async ({ title }) => {
const upper = title.toUpperCase(); // TypeError if title is undefined
}
// ✅ Explicit check
async ({ title }) => {
if (!title) {
return { content: [{ type: "text" as const, text: "title is required" }], isError: true };
}
const upper = title.toUpperCase();
}
"Zod validation error at runtime"
Cause: The model sent a value that doesn't pass Zod's validation. The SDK returns this automatically.
Solution: The SDK handles this automatically. If you see these errors, check that your schema reflects what the model should send. Add clearer .describe() to guide the model.
"Tool runs but returns empty"
Cause: Your handler doesn't return anything in some code path.
Solution:
// ❌ Missing return in a branch
async ({ action }) => {
if (action === "list") {
return { content: [{ type: "text" as const, text: "list" }] };
}
// What happens if action isn't "list"? → undefined
}
// ✅ All paths return
async ({ action }) => {
if (action === "list") {
return { content: [{ type: "text" as const, text: "list" }] };
}
return {
content: [{ type: "text" as const, text: `Action '${action}' not supported` }],
isError: true,
};
}
"Timeout when running a tool with a long operation"
Cause: The tool runs an operation that takes longer than expected.
Solution:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, { signal: controller.signal });
return { content: [{ type: "text" as const, text: await response.text() }] };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { content: [{ type: "text" as const, text: "Timeout: the operation took more than 15 seconds" }], isError: true };
}
throw error;
} finally {
clearTimeout(timeoutId);
}
Exercises
Exercise 1: Tool with enum and validation (Easy)
Implement a format_text tool that takes a text and a format (uppercase, lowercase, title_case, reverse) and returns the transformed text.
See solution
server.tool(
"format_text",
"Transforms text to the specified format: uppercase, lowercase, title_case, or reverse",
{
text: z.string().min(1).describe("Text to transform"),
format: z.enum(["uppercase", "lowercase", "title_case", "reverse"])
.describe("Output format"),
},
async ({ text, format }) => {
let result: string;
switch (format) {
case "uppercase":
result = text.toUpperCase();
break;
case "lowercase":
result = text.toLowerCase();
break;
case "title_case":
result = text.replace(/\b\w/g, char => char.toUpperCase());
break;
case "reverse":
result = text.split("").reverse().join("");
break;
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({ original: text, format, result }, null, 2),
}],
};
}
);
Exercise 2: Tool with nested objects (Medium)
Implement a create_config tool that takes an app name, environment (dev/staging/prod), and a configuration object with port, debug, and database.host/database.name. Write the configuration as a JSON file.
See solution
server.tool(
"create_config",
"Creates a JSON configuration file for an application with the specified parameters",
{
appName: z.string().min(1).describe("Name of the application"),
environment: z.enum(["development", "staging", "production"])
.describe("Application environment"),
port: z.number().int().min(1).max(65535).default(3000)
.describe("Server port"),
debug: z.boolean().default(false)
.describe("Enable debug mode"),
database: z.object({
host: z.string().describe("Database host"),
name: z.string().describe("Database name"),
port: z.number().int().default(5432).describe("Database port"),
}).describe("Database configuration"),
},
async ({ appName, environment, port, debug, database }) => {
const config = {
app: {
name: appName,
environment,
port,
debug,
},
database,
createdAt: new Date().toISOString(),
};
const filename = `config.${environment}.json`;
await fs.writeFile(filename, JSON.stringify(config, null, 2), "utf-8");
return {
content: [{
type: "text" as const,
text: JSON.stringify({
message: `Configuration created: ${filename}`,
config,
}, null, 2),
}],
};
}
);
Exercise 3: Tool with dryRun (Medium)
Implement a bulk_rename tool that renames files in a directory, replacing a pattern in the name. Include a dryRun mode (default: true).
See solution
server.tool(
"bulk_rename",
"Renames files in a directory by replacing a pattern in the name. Use dryRun for a preview.",
{
directory: z.string().describe("Directory with the files"),
search: z.string().min(1).describe("Text to search for in the names"),
replace: z.string().describe("Replacement text"),
dryRun: z.boolean().default(true).describe("If true, only shows a preview"),
},
async ({ directory, search, replace, dryRun }) => {
const entries = await fs.readdir(directory);
const changes: Array<{ from: string; to: string }> = [];
for (const entry of entries) {
if (entry.includes(search)) {
changes.push({ from: entry, to: entry.replace(search, replace) });
}
}
if (changes.length === 0) {
return {
content: [{ type: "text" as const, text: `No files found with '${search}' in ${directory}` }],
};
}
if (dryRun) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
mode: "dryRun",
totalChanges: changes.length,
changes,
hint: "Use dryRun: false to execute",
}, null, 2),
}],
};
}
for (const { from, to } of changes) {
await fs.rename(path.join(directory, from), path.join(directory, to));
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
mode: "executed",
renamed: changes.length,
changes,
}, null, 2),
}],
};
}
);
Exercise 4: Tool that calls an external API (Hard)
Implement a check_url_status tool that takes a URL (or array of URLs), makes a HEAD request to each one, and returns the status code, response time, and relevant headers.
See solution
server.tool(
"check_url_status",
"Checks the status of one or more URLs with HEAD requests. Returns status code, response time, and headers.",
{
urls: z.array(z.string().url()).min(1).max(10)
.describe("URLs to check (maximum 10)"),
timeoutMs: z.number().int().positive().default(5000)
.describe("Timeout per URL in milliseconds"),
},
async ({ urls, timeoutMs }) => {
const results = await Promise.allSettled(
urls.map(async (url) => {
const start = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "HEAD",
signal: controller.signal,
});
const elapsed = Date.now() - start;
return {
url,
status: response.status,
statusText: response.statusText,
responseTimeMs: elapsed,
contentType: response.headers.get("content-type"),
server: response.headers.get("server"),
};
} catch (error) {
const elapsed = Date.now() - start;
return {
url,
error: error instanceof Error ? error.message : "Unknown error",
responseTimeMs: elapsed,
};
} finally {
clearTimeout(timeout);
}
})
);
const formattedResults = results.map((result) =>
result.status === "fulfilled" ? result.value : { error: "Promise rejected" }
);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
checked: urls.length,
results: formattedResults,
}, null, 2),
}],
};
}
);
Exercise 5: Complete CRUD tool (Hard)
Implement a manage_contacts tool that allows creating, listing, searching, and deleting contacts in a JSON file. Include email and phone validation.
See solution
const CONTACTS_FILE = path.join(process.cwd(), "contacts.json");
interface Contact {
id: string;
name: string;
email: string;
phone?: string;
createdAt: string;
}
async function loadContacts(): Promise<Contact[]> {
try {
const data = await fs.readFile(CONTACTS_FILE, "utf-8");
return JSON.parse(data);
} catch {
return [];
}
}
async function saveContacts(contacts: Contact[]): Promise<void> {
await fs.writeFile(CONTACTS_FILE, JSON.stringify(contacts, null, 2), "utf-8");
}
server.tool(
"manage_contacts",
"Manages a list of contacts: create, list, search by name/email, or delete by ID",
{
action: z.enum(["create", "list", "search", "delete"])
.describe("Action to perform"),
name: z.string().optional().describe("Contact name (for create/search)"),
email: z.string().email().optional().describe("Contact email (for create/search)"),
phone: z.string().optional().describe("Contact phone (for create)"),
contactId: z.string().optional().describe("Contact ID (for delete)"),
},
async ({ action, name, email, phone, contactId }) => {
const contacts = await loadContacts();
switch (action) {
case "create": {
if (!name || !email) {
return { content: [{ type: "text" as const, text: "Error: name and email are required" }], isError: true };
}
const newContact: Contact = {
id: crypto.randomUUID(),
name,
email,
phone,
createdAt: new Date().toISOString(),
};
contacts.push(newContact);
await saveContacts(contacts);
return { content: [{ type: "text" as const, text: JSON.stringify({ created: newContact }, null, 2) }] };
}
case "list":
return { content: [{ type: "text" as const, text: JSON.stringify({ total: contacts.length, contacts }, null, 2) }] };
case "search": {
const query = (name || email || "").toLowerCase();
const found = contacts.filter(c =>
c.name.toLowerCase().includes(query) || c.email.toLowerCase().includes(query)
);
return { content: [{ type: "text" as const, text: JSON.stringify({ query, found: found.length, results: found }, null, 2) }] };
}
case "delete": {
if (!contactId) {
return { content: [{ type: "text" as const, text: "Error: contactId is required" }], isError: true };
}
const idx = contacts.findIndex(c => c.id === contactId);
if (idx === -1) {
return { content: [{ type: "text" as const, text: `Contact ${contactId} not found` }], isError: true };
}
const removed = contacts.splice(idx, 1)[0];
await saveContacts(contacts);
return { content: [{ type: "text" as const, text: JSON.stringify({ deleted: removed }, null, 2) }] };
}
}
}
);
Summary
In this capsule you learned:
server.tool()takes 4 arguments: name, description, Zod schema, handler- Zod schemas go from simple (
z.string()) to complex (z.object(),z.array(), chained validations) .describe()is essential — it's what the model reads to know what to send- Error handling has two levels: expected errors (
isError: true) and unexpected errors (try/catch) - Design patterns: dryRun for destructive operations, structured JSON in outputs, idempotency when possible
- Zod vs manual: Zod is more concise, typed, and generates JSON Schema automatically
Most of the time you spend building an MCP server, you spend implementing tools. You'll use the patterns from this capsule in every server you create.
Additional resources
- Zod Documentation - Complete reference of types and validations
- MCP Specification — Tools - Official specification
- MCP TypeScript SDK — Examples - SDK examples
- JSON Schema - Underlying format generated by Zod
- Node.js fs/promises API - Filesystem API used in the examples
- Building Effective Tools (Anthropic) - Best practices for tool design
Next capsule: Implement Resources — contextual data with static URIs and dynamic templates. How to make your server expose data the model can query.