# How to build a multi-tool MCP server

- Tool: MCP
- Difficulty: Advanced
- Time required: 30-45 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+, Claude Desktop / Cursor / Windsurf
- Last updated: March 2026

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

## Before you start

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

```
# Project structure:
# src/
#   index.ts           <- entry point
#   context.ts         <- shared state
#   tools/
#     list-files.ts
#     read-file.ts
#     search-files.ts
#     get-stats.ts
#     write-file.ts
#   helpers/
#     format-response.ts
#     validate-path.ts

npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx 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.

```
// src/context.ts
import fs from "fs/promises";
import path from "path";

export interface ServerContext {
  basePath: string;
  maxFileSize: number;
  allowedExtensions: string[];
  stats: { toolCalls: Record<string, number> };
}

export function createContext(basePath: string): ServerContext {
  return {
    basePath: path.resolve(basePath),
    maxFileSize: 10 * 1024 * 1024, // 10MB
    allowedExtensions: [".ts", ".js", ".json", ".md", ".txt"],
    stats: { toolCalls: {} },
  };
}

export function trackCall(ctx: ServerContext, toolName: string): void {
  ctx.stats.toolCalls[toolName] = (ctx.stats.toolCalls[toolName] || 0) + 1;
}
```

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

```
// src/tools/list-files.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
import { ServerContext, trackCall } from "../context.js";

export function registerListFiles(server: McpServer, ctx: ServerContext) {
  server.tool(
    "list_files",
    "List files in a directory within the project",
    {
      directory: z.string().default(".").describe("Relative path from project root"),
      recursive: z.boolean().default(false).describe("Include subdirectories"),
    },
    async ({ directory, recursive }) => {
      trackCall(ctx, "list_files");
      const fullPath = path.join(ctx.basePath, directory);
      
      if (!fullPath.startsWith(ctx.basePath)) {
        return { content: [{ type: "text", text: "Error: Path traversal not allowed" }], isError: true };
      }

      const entries = await fs.readdir(fullPath, { withFileTypes: true, recursive });
      const files = entries.map(e => ({
        name: e.name,
        type: e.isDirectory() ? "directory" : "file",
      }));

      return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
    }
  );
}
```

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

```
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createContext } from "./context.js";
import { registerListFiles } from "./tools/list-files.js";
import { registerReadFile } from "./tools/read-file.js";
import { registerSearchFiles } from "./tools/search-files.js";
import { registerGetStats } from "./tools/get-stats.js";
import { registerWriteFile } from "./tools/write-file.js";

const server = new McpServer({
  name: "multi-tool-file-server",
  version: "1.0.0",
});

const ctx = createContext(process.env.PROJECT_PATH || ".");

// Register all tools
registerListFiles(server, ctx);
registerReadFile(server, ctx);
registerSearchFiles(server, ctx);
registerGetStats(server, ctx);
registerWriteFile(server, ctx);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error(`Multi-tool server running with ${Object.keys(ctx.stats.toolCalls).length || 5} tools`);
}

main().catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});
```

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

```
// src/helpers/format-response.ts
export function textResult(text: string) {
  return { content: [{ type: "text" as const, text }] };
}

export function errorResult(message: string) {
  return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true as const };
}

export function jsonResult(data: unknown) {
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

// src/helpers/validate-path.ts
import path from "path";

export function validatePath(basePath: string, relativePath: string): string | null {
  const resolved = path.resolve(basePath, relativePath);
  if (!resolved.startsWith(basePath)) return null;
  return resolved;
}
```

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

```
# Build the project
npx tsc

# Run with MCP Inspector
npx @modelcontextprotocol/inspector node dist/index.js

# The inspector opens a browser UI where you can:
# 1. See all 5 registered tools
# 2. Fill in parameters and call each tool
# 3. Inspect JSON-RPC messages
# 4. Verify error handling
```

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

## Complete code example

File: `src/index.ts`

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";

// --- Context ---
interface ServerContext {
  basePath: string;
  maxFileSize: number;
  stats: { toolCalls: Record<string, number> };
}

function createContext(basePath: string): ServerContext {
  return {
    basePath: path.resolve(basePath),
    maxFileSize: 10 * 1024 * 1024,
    stats: { toolCalls: {} },
  };
}

function track(ctx: ServerContext, name: string) {
  ctx.stats.toolCalls[name] = (ctx.stats.toolCalls[name] || 0) + 1;
}

function textResult(text: string) {
  return { content: [{ type: "text" as const, text }] };
}

function errorResult(msg: string) {
  return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true as const };
}

function safePath(base: string, rel: string): string | null {
  const full = path.resolve(base, rel);
  return full.startsWith(base) ? full : null;
}

// --- Server setup ---
const server = new McpServer({ name: "multi-tool-server", version: "1.0.0" });
const ctx = createContext(process.env.PROJECT_PATH || ".");

server.tool("list_files", "List files in a directory", {
  directory: z.string().default("."),
  recursive: z.boolean().default(false),
}, async ({ directory, recursive }) => {
  track(ctx, "list_files");
  const p = safePath(ctx.basePath, directory);
  if (!p) return errorResult("Path traversal not allowed");
  const entries = await fs.readdir(p, { withFileTypes: true, recursive });
  return textResult(JSON.stringify(entries.map(e => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })), null, 2));
});

server.tool("read_file", "Read a file's contents", {
  filePath: z.string(),
}, async ({ filePath }) => {
  track(ctx, "read_file");
  const p = safePath(ctx.basePath, filePath);
  if (!p) return errorResult("Path traversal not allowed");
  const stat = await fs.stat(p);
  if (stat.size > ctx.maxFileSize) return errorResult("File too large");
  const content = await fs.readFile(p, "utf-8");
  return textResult(content);
});

server.tool("search_files", "Search for files by name pattern", {
  pattern: z.string(),
  directory: z.string().default("."),
}, async ({ pattern, directory }) => {
  track(ctx, "search_files");
  const p = safePath(ctx.basePath, directory);
  if (!p) return errorResult("Path traversal not allowed");
  const entries = await fs.readdir(p, { withFileTypes: true, recursive: true });
  const regex = new RegExp(pattern, "i");
  const matches = entries.filter(e => e.isFile() && regex.test(e.name)).map(e => e.name);
  return textResult(JSON.stringify(matches, null, 2));
});

server.tool("write_file", "Write content to a file", {
  filePath: z.string(),
  content: z.string(),
}, async ({ filePath, content }) => {
  track(ctx, "write_file");
  const p = safePath(ctx.basePath, filePath);
  if (!p) return errorResult("Path traversal not allowed");
  await fs.mkdir(path.dirname(p), { recursive: true });
  await fs.writeFile(p, content, "utf-8");
  return textResult(`Wrote ${content.length} bytes to ${filePath}`);
});

server.tool("get_stats", "Get server usage statistics", {}, async () => {
  track(ctx, "get_stats");
  return textResult(JSON.stringify({ basePath: ctx.basePath, calls: ctx.stats.toolCalls }, null, 2));
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Multi-tool MCP server running");
}

main().catch(e => { console.error(e); process.exit(1); });
```

## Common mistakes

- **Logging to stdout instead of stderr, which corrupts the JSON-RPC communication channel** — undefined Fix: Always use console.error() for logging. MCP reserves stdout for protocol messages.
- **Creating global mutable state instead of passing context to tools** — undefined Fix: Use a context object created at startup and passed explicitly to each tool registration function.
- **Not validating file paths, allowing directory traversal attacks** — undefined Fix: Always resolve paths and check they start with the base directory before any file operation.
- **Registering tools with the same name, causing silent overwrites** — undefined Fix: 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

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

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-build-multi-tool-mcp-server
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-build-multi-tool-mcp-server
