Skip to main content
RapidDev - Software Development Agency
mcp-tutorial

How to build a multi-tool MCP server

Build a multi-tool MCP server by organizing tools into logical modules, sharing state through a context object, and registering each tool with the McpServer class. Use helper functions to reduce duplication, define clear input schemas with Zod, and keep tool handlers focused on a single responsibility. This pattern scales cleanly to dozens of tools.

What you'll learn

  • How to structure a multi-tool MCP server with modular files
  • How to share state and dependencies across tool handlers
  • How to register 5+ tools with typed Zod schemas
  • How to build reusable helper functions for common tool patterns
  • How to test multi-tool servers with MCP Inspector
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced10 min read30-45 minMCP TypeScript SDK v1.x, Node.js 18+, Claude Desktop / Cursor / WindsurfMarch 2026RapidDev Engineering Team
TL;DR

Build a multi-tool MCP server by organizing tools into logical modules, sharing state through a context object, and registering each tool with the McpServer class. Use helper functions to reduce duplication, define clear input schemas with Zod, and keep tool handlers focused on a single responsibility. This pattern scales cleanly to dozens of tools.

Organizing Large MCP Servers with Multiple Tools

As your MCP server grows beyond two or three tools, a single file becomes unmanageable. This tutorial shows how to split tools into separate modules, share database connections and API clients through a shared context object, and register everything cleanly with McpServer. You will build a server with five tools that share a common HTTP client and configuration, following patterns that scale to production workloads.

Prerequisites

  • Node.js 18+ and npm installed
  • Basic TypeScript knowledge
  • MCP TypeScript SDK installed (@modelcontextprotocol/sdk)
  • Familiarity with building a basic single-tool MCP server
  • A code editor such as VS Code or Cursor

Step-by-step guide

1

Set up the project structure with separate tool modules

Create a project directory with a clear folder structure. Each tool gets its own file inside a tools/ directory. A shared/ directory holds helper functions and the context object. The main entry point index.ts imports and registers all tools. This separation makes it easy to add, remove, or modify individual tools without touching unrelated code. Keep the index.ts file thin — it should only wire things together.

typescript
1# Project structure:
2# src/
3# index.ts <- entry point
4# context.ts <- shared state
5# tools/
6# list-files.ts
7# read-file.ts
8# search-files.ts
9# get-stats.ts
10# write-file.ts
11# helpers/
12# format-response.ts
13# validate-path.ts
14
15npm init -y
16npm install @modelcontextprotocol/sdk zod
17npm install -D typescript @types/node
18npx tsc --init

Expected result: A project directory with src/tools/, src/helpers/, and src/context.ts files ready for implementation.

2

Create the shared context object for cross-tool state

Define a context class that holds shared resources like configuration, database connections, or API clients. Every tool handler receives this context, so they can access shared state without global variables. The context is created once at startup and passed to each tool registration function. This pattern avoids tight coupling between tools and makes testing easier because you can inject mock contexts.

typescript
1// src/context.ts
2import fs from "fs/promises";
3import path from "path";
4
5export interface ServerContext {
6 basePath: string;
7 maxFileSize: number;
8 allowedExtensions: string[];
9 stats: { toolCalls: Record<string, number> };
10}
11
12export function createContext(basePath: string): ServerContext {
13 return {
14 basePath: path.resolve(basePath),
15 maxFileSize: 10 * 1024 * 1024, // 10MB
16 allowedExtensions: [".ts", ".js", ".json", ".md", ".txt"],
17 stats: { toolCalls: {} },
18 };
19}
20
21export function trackCall(ctx: ServerContext, toolName: string): void {
22 ctx.stats.toolCalls[toolName] = (ctx.stats.toolCalls[toolName] || 0) + 1;
23}

Expected result: A context.ts file that exports a factory function returning a typed context object.

3

Build individual tool modules with typed schemas

Each tool module exports a registration function that takes the McpServer instance and the shared context. Inside, it calls server.tool() with a name, description, Zod schema for inputs, and the handler function. The handler uses the context for shared state and returns the standard MCP result format. Keep each tool focused on one action — list files, read a file, search, etc.

typescript
1// src/tools/list-files.ts
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { z } from "zod";
4import fs from "fs/promises";
5import path from "path";
6import { ServerContext, trackCall } from "../context.js";
7
8export function registerListFiles(server: McpServer, ctx: ServerContext) {
9 server.tool(
10 "list_files",
11 "List files in a directory within the project",
12 {
13 directory: z.string().default(".").describe("Relative path from project root"),
14 recursive: z.boolean().default(false).describe("Include subdirectories"),
15 },
16 async ({ directory, recursive }) => {
17 trackCall(ctx, "list_files");
18 const fullPath = path.join(ctx.basePath, directory);
19
20 if (!fullPath.startsWith(ctx.basePath)) {
21 return { content: [{ type: "text", text: "Error: Path traversal not allowed" }], isError: true };
22 }
23
24 const entries = await fs.readdir(fullPath, { withFileTypes: true, recursive });
25 const files = entries.map(e => ({
26 name: e.name,
27 type: e.isDirectory() ? "directory" : "file",
28 }));
29
30 return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
31 }
32 );
33}

Expected result: A tool module that registers one tool with the server and uses the shared context.

4

Wire all tools together in the main entry point

The index.ts file creates the McpServer, initializes the shared context, imports all tool registration functions, and calls each one. Finally, it connects the server to a transport. This file should be short and declarative — it is the wiring layer, not the logic layer. If you need to add a new tool, you create a new file in tools/ and add one import plus one function call here.

typescript
1// src/index.ts
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4import { createContext } from "./context.js";
5import { registerListFiles } from "./tools/list-files.js";
6import { registerReadFile } from "./tools/read-file.js";
7import { registerSearchFiles } from "./tools/search-files.js";
8import { registerGetStats } from "./tools/get-stats.js";
9import { registerWriteFile } from "./tools/write-file.js";
10
11const server = new McpServer({
12 name: "multi-tool-file-server",
13 version: "1.0.0",
14});
15
16const ctx = createContext(process.env.PROJECT_PATH || ".");
17
18// Register all tools
19registerListFiles(server, ctx);
20registerReadFile(server, ctx);
21registerSearchFiles(server, ctx);
22registerGetStats(server, ctx);
23registerWriteFile(server, ctx);
24
25async function main() {
26 const transport = new StdioServerTransport();
27 await server.connect(transport);
28 console.error(`Multi-tool server running with ${Object.keys(ctx.stats.toolCalls).length || 5} tools`);
29}
30
31main().catch((error) => {
32 console.error("Fatal error:", error);
33 process.exit(1);
34});

Expected result: Running the server shows a startup message on stderr and the server accepts tool calls from any MCP client.

5

Create helper functions to reduce duplication across tools

When multiple tools need the same validation, error formatting, or path resolution logic, extract it into a helper. For example, a formatResponse helper standardizes the MCP content array format, and a validatePath helper checks for traversal attacks. Helpers keep tool handlers clean and ensure consistent behavior. This is especially important when you have five or more tools that all need the same safety checks.

typescript
1// src/helpers/format-response.ts
2export function textResult(text: string) {
3 return { content: [{ type: "text" as const, text }] };
4}
5
6export function errorResult(message: string) {
7 return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true as const };
8}
9
10export function jsonResult(data: unknown) {
11 return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
12}
13
14// src/helpers/validate-path.ts
15import path from "path";
16
17export function validatePath(basePath: string, relativePath: string): string | null {
18 const resolved = path.resolve(basePath, relativePath);
19 if (!resolved.startsWith(basePath)) return null;
20 return resolved;
21}

Expected result: Helper functions are imported by multiple tool modules, eliminating duplicated validation and formatting code.

6

Test the multi-tool server with MCP Inspector

Use the MCP Inspector to verify all tools are registered and working. The Inspector connects to your server and shows a UI where you can call each tool, see the schema, and inspect responses. Build the project first, then run the inspector pointing at your compiled output. Test each tool individually and verify the shared context (like call tracking) works across tools. For complex multi-tool setups, teams like RapidDev recommend automated integration tests alongside manual Inspector checks.

typescript
1# Build the project
2npx tsc
3
4# Run with MCP Inspector
5npx @modelcontextprotocol/inspector node dist/index.js
6
7# The inspector opens a browser UI where you can:
8# 1. See all 5 registered tools
9# 2. Fill in parameters and call each tool
10# 3. Inspect JSON-RPC messages
11# 4. Verify error handling

Expected result: MCP Inspector shows all five tools with their schemas, and each tool returns correct results when called.

Complete working example

src/index.ts
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { z } from "zod";
4import fs from "fs/promises";
5import path from "path";
6
7// --- Context ---
8interface ServerContext {
9 basePath: string;
10 maxFileSize: number;
11 stats: { toolCalls: Record<string, number> };
12}
13
14function createContext(basePath: string): ServerContext {
15 return {
16 basePath: path.resolve(basePath),
17 maxFileSize: 10 * 1024 * 1024,
18 stats: { toolCalls: {} },
19 };
20}
21
22function track(ctx: ServerContext, name: string) {
23 ctx.stats.toolCalls[name] = (ctx.stats.toolCalls[name] || 0) + 1;
24}
25
26function textResult(text: string) {
27 return { content: [{ type: "text" as const, text }] };
28}
29
30function errorResult(msg: string) {
31 return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true as const };
32}
33
34function safePath(base: string, rel: string): string | null {
35 const full = path.resolve(base, rel);
36 return full.startsWith(base) ? full : null;
37}
38
39// --- Server setup ---
40const server = new McpServer({ name: "multi-tool-server", version: "1.0.0" });
41const ctx = createContext(process.env.PROJECT_PATH || ".");
42
43server.tool("list_files", "List files in a directory", {
44 directory: z.string().default("."),
45 recursive: z.boolean().default(false),
46}, async ({ directory, recursive }) => {
47 track(ctx, "list_files");
48 const p = safePath(ctx.basePath, directory);
49 if (!p) return errorResult("Path traversal not allowed");
50 const entries = await fs.readdir(p, { withFileTypes: true, recursive });
51 return textResult(JSON.stringify(entries.map(e => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })), null, 2));
52});
53
54server.tool("read_file", "Read a file's contents", {
55 filePath: z.string(),
56}, async ({ filePath }) => {
57 track(ctx, "read_file");
58 const p = safePath(ctx.basePath, filePath);
59 if (!p) return errorResult("Path traversal not allowed");
60 const stat = await fs.stat(p);
61 if (stat.size > ctx.maxFileSize) return errorResult("File too large");
62 const content = await fs.readFile(p, "utf-8");
63 return textResult(content);
64});
65
66server.tool("search_files", "Search for files by name pattern", {
67 pattern: z.string(),
68 directory: z.string().default("."),
69}, async ({ pattern, directory }) => {
70 track(ctx, "search_files");
71 const p = safePath(ctx.basePath, directory);
72 if (!p) return errorResult("Path traversal not allowed");
73 const entries = await fs.readdir(p, { withFileTypes: true, recursive: true });
74 const regex = new RegExp(pattern, "i");
75 const matches = entries.filter(e => e.isFile() && regex.test(e.name)).map(e => e.name);
76 return textResult(JSON.stringify(matches, null, 2));
77});
78
79server.tool("write_file", "Write content to a file", {
80 filePath: z.string(),
81 content: z.string(),
82}, async ({ filePath, content }) => {
83 track(ctx, "write_file");
84 const p = safePath(ctx.basePath, filePath);
85 if (!p) return errorResult("Path traversal not allowed");
86 await fs.mkdir(path.dirname(p), { recursive: true });
87 await fs.writeFile(p, content, "utf-8");
88 return textResult(`Wrote ${content.length} bytes to ${filePath}`);
89});
90
91server.tool("get_stats", "Get server usage statistics", {}, async () => {
92 track(ctx, "get_stats");
93 return textResult(JSON.stringify({ basePath: ctx.basePath, calls: ctx.stats.toolCalls }, null, 2));
94});
95
96async function main() {
97 const transport = new StdioServerTransport();
98 await server.connect(transport);
99 console.error("Multi-tool MCP server running");
100}
101
102main().catch(e => { console.error(e); process.exit(1); });

Common mistakes when building a multi-tool MCP server

Why it's a problem: Logging to stdout instead of stderr, which corrupts the JSON-RPC communication channel

How to avoid: Always use console.error() for logging. MCP reserves stdout for protocol messages.

Why it's a problem: Creating global mutable state instead of passing context to tools

How to avoid: Use a context object created at startup and passed explicitly to each tool registration function.

Why it's a problem: Not validating file paths, allowing directory traversal attacks

How to avoid: Always resolve paths and check they start with the base directory before any file operation.

Why it's a problem: Registering tools with the same name, causing silent overwrites

How to avoid: Use unique, descriptive tool names with a consistent naming convention like snake_case.

Best practices

  • Keep each tool in its own file with a single registration function
  • Use a shared context object for cross-tool state instead of global variables
  • Validate all file paths against a base directory to prevent traversal attacks
  • Return isError: true for error responses so the LLM knows something went wrong
  • Log to stderr only — stdout is reserved for MCP protocol messages
  • Use Zod schemas with .describe() to give the LLM clear parameter documentation
  • Track tool call counts in the context for monitoring and debugging
  • Keep the entry point file thin — it should only wire components together

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm building an MCP server in TypeScript with 5+ tools. Show me how to organize tools into separate modules with a shared context object, register them all in index.ts, and include path validation helpers. Use @modelcontextprotocol/sdk.

MCP Prompt

Create a multi-tool MCP server with list_files, read_file, search_files, write_file, and get_stats tools. Use modular file structure with shared context. Include Zod schemas for all inputs and path traversal protection.

Frequently asked questions

How many tools can a single MCP server have?

There is no hard limit in the MCP protocol, but practically you should keep it under 50 tools per server. Beyond that, LLMs struggle to select the right tool because the tool list consumes too much context window. Split into multiple specialized servers if you need more.

Should I use one server with many tools or many servers with one tool each?

Group related tools into one server. Tools that share state, like a database connection or API client, belong together. Unrelated capabilities like file operations and email sending should be separate servers.

Can tools call other tools within the same server?

Tools cannot call each other through the MCP protocol, but they can share logic through imported helper functions. Extract common operations into helpers that multiple tool handlers call directly.

How do I handle errors in tool handlers?

Return an object with isError: true and a descriptive error message in the content array. Never throw unhandled exceptions from a tool handler — catch errors and return them as error results.

What naming convention should I use for tools?

Use snake_case for tool names (list_files, read_file) as this is the most common convention in the MCP ecosystem. Be descriptive but concise — the LLM uses tool names and descriptions to decide which tool to call.

Can RapidDev help with building complex multi-tool MCP servers?

Yes, RapidDev's engineering team has experience building production MCP servers with dozens of tools. They can help with architecture decisions, shared state patterns, and deployment strategies for complex setups.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.