Build a Multi-Tool MCP Server
Learn how to combine multiple tools into a single MCP server. Build a productivity server with time, math, text manipulation, and utility tools that work together.
title: "Build a Multi-Tool MCP Server" description: "Learn how to combine multiple tools into a single MCP server. Build a productivity server with time, math, text manipulation, and utility tools that work together." order: 9 keywords:
- multi-tool mcp server
- combine mcp tools
- mcp server multiple tools
- productivity mcp server
- mcp tool composition date: "2026-04-01" level: "intermediate" duration: "30 min"
Learn how to build an MCP server that combines multiple tools into a single cohesive server. You will create a productivity toolkit with time conversion, text manipulation, math evaluation, and UUID generation tools -- all in one server that Claude can use together.
Why Multi-Tool Servers?
Most real-world MCP servers expose multiple related tools. Rather than running separate servers for each small utility, grouping related tools improves performance and simplifies deployment.
The practice of designing multiple MCP tools that work together within a single server. Each tool has a single responsibility, but together they form a complete solution. The AI assistant orchestrates which tools to call and in what order.
What You Will Build
A productivity toolkit server with five tools:
- convert_timezone -- Convert between time zones
- evaluate_math -- Safely evaluate math expressions
- transform_text -- Text transformations (case, encoding, hashing)
- generate_uuid -- Generate UUIDs and random IDs
- json_format -- Format, minify, or validate JSON
Project Setup
npx mcp-framework create multi-tool-server
cd multi-tool-server
Building the Tools
ConvertTimezoneTool
Create src/tools/ConvertTimezoneTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
class ConvertTimezoneTool extends MCPTool<typeof inputSchema> {
name = "convert_timezone";
description = "Convert a time from one timezone to another. Uses IANA timezone names.";
schema = {
time: {
type: z.string(),
description: "Time to convert in ISO 8601 format (e.g., '2026-04-01T14:30:00')",
},
fromTimezone: {
type: z.string(),
description: "Source IANA timezone (e.g., 'America/New_York')",
},
toTimezone: {
type: z.string(),
description: "Target IANA timezone (e.g., 'Europe/London')",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const date = new Date(input.time);
if (isNaN(date.getTime())) {
return JSON.stringify({ error: "Invalid date/time format" });
}
const fromFormatted = date.toLocaleString("en-US", {
timeZone: input.fromTimezone,
dateStyle: "full",
timeStyle: "long",
});
const toFormatted = date.toLocaleString("en-US", {
timeZone: input.toTimezone,
dateStyle: "full",
timeStyle: "long",
});
return JSON.stringify({
from: { timezone: input.fromTimezone, time: fromFormatted },
to: { timezone: input.toTimezone, time: toFormatted },
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
time: z.string(),
fromTimezone: z.string(),
toTimezone: z.string(),
});
export default ConvertTimezoneTool;
EvaluateMathTool
Create src/tools/EvaluateMathTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
class EvaluateMathTool extends MCPTool<typeof inputSchema> {
name = "evaluate_math";
description = "Safely evaluate a mathematical expression. Supports basic arithmetic, exponents, and common functions.";
schema = {
expression: {
type: z.string().min(1),
description: "Math expression to evaluate (e.g., '2 * (3 + 4)' or 'Math.sqrt(144)')",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
// Whitelist allowed characters to prevent code injection
const sanitized = input.expression.replace(/\s/g, "");
const allowed = /^[0-9+\-*/%().^,eE]+$|^Math\.\w+\([^)]*\)$/;
// Simple safe evaluation for basic math
const safeExpression = input.expression
.replace(/\^/g, "**")
.replace(/[^0-9+\-*/%().** Math.sqrtpowabsceilfloorround,eE]/g, "");
// Use Function constructor in a limited way
const result = Function(`"use strict"; return (${safeExpression})`)();
if (typeof result !== "number" || !isFinite(result)) {
return JSON.stringify({ error: "Expression did not evaluate to a finite number" });
}
return JSON.stringify({
expression: input.expression,
result,
formatted: result.toLocaleString(),
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: `Evaluation failed: ${message}` });
}
}
}
const inputSchema = z.object({
expression: z.string().min(1),
});
export default EvaluateMathTool;
Never use eval() on untrusted input. The example above uses basic sanitization, but for production use consider a proper math parser library like mathjs that provides safe evaluation without arbitrary code execution.
TransformTextTool
Create src/tools/TransformTextTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { createHash } from "crypto";
class TransformTextTool extends MCPTool<typeof inputSchema> {
name = "transform_text";
description = "Transform text: change case, encode/decode, count characters, or generate hashes";
schema = {
text: {
type: z.string(),
description: "The input text to transform",
},
operation: {
type: z.enum([
"uppercase", "lowercase", "titlecase",
"base64_encode", "base64_decode",
"url_encode", "url_decode",
"md5", "sha256",
"reverse", "word_count", "char_count",
]),
description: "The transformation to apply",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
const { text, operation } = input;
const operations: Record<string, () => string | number> = {
uppercase: () => text.toUpperCase(),
lowercase: () => text.toLowerCase(),
titlecase: () => text.replace(/\b\w/g, (c) => c.toUpperCase()),
base64_encode: () => Buffer.from(text).toString("base64"),
base64_decode: () => Buffer.from(text, "base64").toString("utf-8"),
url_encode: () => encodeURIComponent(text),
url_decode: () => decodeURIComponent(text),
md5: () => createHash("md5").update(text).digest("hex"),
sha256: () => createHash("sha256").update(text).digest("hex"),
reverse: () => text.split("").reverse().join(""),
word_count: () => text.split(/\s+/).filter(Boolean).length,
char_count: () => text.length,
};
try {
const result = operations[operation]();
return JSON.stringify({
input: text.substring(0, 100) + (text.length > 100 ? "..." : ""),
operation,
result,
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
text: z.string(),
operation: z.enum([
"uppercase", "lowercase", "titlecase",
"base64_encode", "base64_decode",
"url_encode", "url_decode",
"md5", "sha256",
"reverse", "word_count", "char_count",
]),
});
export default TransformTextTool;
GenerateUuidTool
Create src/tools/GenerateUuidTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { randomUUID, randomBytes } from "crypto";
class GenerateUuidTool extends MCPTool<typeof inputSchema> {
name = "generate_uuid";
description = "Generate UUIDs, random IDs, or random hex strings";
schema = {
type: {
type: z.enum(["uuid", "hex", "alphanumeric"]).optional(),
description: "Type of ID to generate (default: uuid)",
},
count: {
type: z.number().min(1).max(20).optional(),
description: "Number of IDs to generate (default: 1, max: 20)",
},
length: {
type: z.number().min(4).max(64).optional(),
description: "Length for hex/alphanumeric IDs (default: 16)",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
const type = input.type || "uuid";
const count = input.count || 1;
const length = input.length || 16;
const ids: string[] = [];
for (let i = 0; i < count; i++) {
switch (type) {
case "uuid":
ids.push(randomUUID());
break;
case "hex":
ids.push(randomBytes(Math.ceil(length / 2)).toString("hex").substring(0, length));
break;
case "alphanumeric": {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let id = "";
const bytes = randomBytes(length);
for (let j = 0; j < length; j++) {
id += chars[bytes[j] % chars.length];
}
ids.push(id);
break;
}
}
}
return JSON.stringify({
type,
count: ids.length,
ids: ids.length === 1 ? ids[0] : ids,
}, null, 2);
}
}
const inputSchema = z.object({
type: z.enum(["uuid", "hex", "alphanumeric"]).optional(),
count: z.number().min(1).max(20).optional(),
length: z.number().min(4).max(64).optional(),
});
export default GenerateUuidTool;
JsonFormatTool
Create src/tools/JsonFormatTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
class JsonFormatTool extends MCPTool<typeof inputSchema> {
name = "json_format";
description = "Format, minify, or validate JSON strings";
schema = {
json: {
type: z.string().min(1),
description: "The JSON string to process",
},
operation: {
type: z.enum(["format", "minify", "validate"]),
description: "What to do: format (pretty-print), minify (compress), or validate (check syntax)",
},
indent: {
type: z.number().min(1).max(8).optional(),
description: "Indentation spaces for format operation (default: 2)",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const parsed = JSON.parse(input.json);
switch (input.operation) {
case "format":
return JSON.stringify({
result: JSON.stringify(parsed, null, input.indent || 2),
valid: true,
}, null, 2);
case "minify":
return JSON.stringify({
result: JSON.stringify(parsed),
originalLength: input.json.length,
minifiedLength: JSON.stringify(parsed).length,
valid: true,
}, null, 2);
case "validate":
return JSON.stringify({
valid: true,
type: Array.isArray(parsed) ? "array" : typeof parsed,
keys: typeof parsed === "object" && parsed !== null ? Object.keys(parsed) : undefined,
}, null, 2);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({
valid: false,
error: `Invalid JSON: ${message}`,
}, null, 2);
}
}
}
const inputSchema = z.object({
json: z.string().min(1),
operation: z.enum(["format", "minify", "validate"]),
indent: z.number().min(1).max(8).optional(),
});
export default JsonFormatTool;
Design Principles for Multi-Tool Servers
Each tool should do one thing well. The AI assistant is excellent at composing multiple simple tools to accomplish complex tasks. Do not create one mega-tool with a mode parameter -- use separate tools with clear names and descriptions.
All tools in a server should return JSON with a consistent structure. Always include the key result plus metadata. If any tool returns errors, use the same error format across all tools.
Official SDK Version
Here is how you would register multiple tools with the official SDK:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { randomUUID } from "crypto";
const server = new McpServer({ name: "multi-tool", version: "1.0.0" });
server.tool("generate_uuid", "Generate a UUID", {
count: z.number().min(1).max(20).optional(),
}, async ({ count }) => {
const ids = Array.from({ length: count || 1 }, () => randomUUID());
return {
content: [{ type: "text" as const, text: JSON.stringify({ ids }, null, 2) }],
};
});
server.tool("transform_text", "Transform text", {
text: z.string(),
operation: z.enum(["uppercase", "lowercase", "reverse"]),
}, async ({ text, operation }) => {
const ops: Record<string, () => string> = {
uppercase: () => text.toUpperCase(),
lowercase: () => text.toLowerCase(),
reverse: () => text.split("").reverse().join(""),
};
return {
content: [{ type: "text" as const, text: JSON.stringify({ result: ops[operation]() }) }],
};
});
// ... register remaining tools ...
const transport = new StdioServerTransport();
await server.connect(transport);
Testing
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Ask Claude: "Generate 5 UUIDs, then convert the current time from New York to Tokyo."