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"

Quick Summary

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.

Tool Composition

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
5 toolsin one server

Project Setup

npx mcp-framework create multi-tool-server
cd multi-tool-server
multi-tool-server
src
tools
ConvertTimezoneTool.ts
EvaluateMathTool.ts
TransformTextTool.ts
GenerateUuidTool.ts
JsonFormatTool.ts
index.ts
package.json
tsconfig.json

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;
Safe Evaluation

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

Single Responsibility Per Tool

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.

Consistent Return Formats

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."

Frequently Asked Questions