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
Set up the project structure with separate tool modules
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.
1# Project structure:2# src/3# index.ts <- entry point4# context.ts <- shared state5# tools/6# list-files.ts7# read-file.ts8# search-files.ts9# get-stats.ts10# write-file.ts11# helpers/12# format-response.ts13# validate-path.ts1415npm init -y16npm install @modelcontextprotocol/sdk zod17npm install -D typescript @types/node18npx tsc --initExpected result: A project directory with src/tools/, src/helpers/, and src/context.ts files ready for implementation.
Create the shared context object for cross-tool state
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.
1// src/context.ts2import fs from "fs/promises";3import path from "path";45export interface ServerContext {6 basePath: string;7 maxFileSize: number;8 allowedExtensions: string[];9 stats: { toolCalls: Record<string, number> };10}1112export function createContext(basePath: string): ServerContext {13 return {14 basePath: path.resolve(basePath),15 maxFileSize: 10 * 1024 * 1024, // 10MB16 allowedExtensions: [".ts", ".js", ".json", ".md", ".txt"],17 stats: { toolCalls: {} },18 };19}2021export 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.
Build individual tool modules with typed schemas
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.
1// src/tools/list-files.ts2import { 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";78export 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 }2324 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 }));2930 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.
Wire all tools together in the main entry point
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.
1// src/index.ts2import { 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";1011const server = new McpServer({12 name: "multi-tool-file-server",13 version: "1.0.0",14});1516const ctx = createContext(process.env.PROJECT_PATH || ".");1718// Register all tools19registerListFiles(server, ctx);20registerReadFile(server, ctx);21registerSearchFiles(server, ctx);22registerGetStats(server, ctx);23registerWriteFile(server, ctx);2425async 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}3031main().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.
Create helper functions to reduce duplication across tools
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.
1// src/helpers/format-response.ts2export function textResult(text: string) {3 return { content: [{ type: "text" as const, text }] };4}56export function errorResult(message: string) {7 return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true as const };8}910export function jsonResult(data: unknown) {11 return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };12}1314// src/helpers/validate-path.ts15import path from "path";1617export 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.
Test the multi-tool server with MCP Inspector
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.
1# Build the project2npx tsc34# Run with MCP Inspector5npx @modelcontextprotocol/inspector node dist/index.js67# The inspector opens a browser UI where you can:8# 1. See all 5 registered tools9# 2. Fill in parameters and call each tool10# 3. Inspect JSON-RPC messages11# 4. Verify error handlingExpected result: MCP Inspector shows all five tools with their schemas, and each tool returns correct results when called.
Complete working example
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";67// --- Context ---8interface ServerContext {9 basePath: string;10 maxFileSize: number;11 stats: { toolCalls: Record<string, number> };12}1314function createContext(basePath: string): ServerContext {15 return {16 basePath: path.resolve(basePath),17 maxFileSize: 10 * 1024 * 1024,18 stats: { toolCalls: {} },19 };20}2122function track(ctx: ServerContext, name: string) {23 ctx.stats.toolCalls[name] = (ctx.stats.toolCalls[name] || 0) + 1;24}2526function textResult(text: string) {27 return { content: [{ type: "text" as const, text }] };28}2930function errorResult(msg: string) {31 return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true as const };32}3334function safePath(base: string, rel: string): string | null {35 const full = path.resolve(base, rel);36 return full.startsWith(base) ? full : null;37}3839// --- Server setup ---40const server = new McpServer({ name: "multi-tool-server", version: "1.0.0" });41const ctx = createContext(process.env.PROJECT_PATH || ".");4243server.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});5354server.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});6566server.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});7879server.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});9091server.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});9596async function main() {97 const transport = new StdioServerTransport();98 await server.connect(transport);99 console.error("Multi-tool MCP server running");100}101102main().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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation