Multi-Tool MCP Servers: Composition Patterns
Complete guide to building MCP servers with multiple tools, covering tool organization, shared state management, tool dependencies, middleware patterns, dynamic tool registration, and composing complex workflows from individual tools.
Multi-Tool MCP Servers: Composition Patterns
Overview
A single-tool MCP server is a demo. A production MCP server has dozens of tools that work together — querying databases, reading files, calling APIs, transforming data, and managing state. The challenge is not writing individual tools but composing them into a coherent server that stays organized as it grows. I have built MCP servers that started with 3 tools and grew to 40, and the ones that survived without becoming unmaintainable all followed the same composition patterns.
Prerequisites
- Node.js 16 or later
@modelcontextprotocol/sdkpackage installed- Understanding of MCP tool basics (list, call, input schemas)
- Experience building at least one simple MCP server
- Familiarity with module patterns in Node.js
The Problem with Monolithic Tool Handlers
When all tools live in a single switch statement, things get ugly fast:
// DON'T DO THIS — the monolith pattern
server.setRequestHandler("tools/call", function(request) {
switch (request.params.name) {
case "query-db": /* 50 lines of database code */
case "read-file": /* 30 lines of file code */
case "call-api": /* 40 lines of API code */
case "format-data": /* 20 lines of formatting */
case "send-email": /* 35 lines of email code */
// ... 30 more cases
}
});
// This file is now 2000 lines and nobody wants to touch it
Pattern 1: Tool Registry
Organize tools into a registry where each tool is a self-contained module.
// tool-registry.js
function ToolRegistry() {
this.tools = {};
}
ToolRegistry.prototype.register = function(name, definition) {
if (this.tools[name]) {
throw new Error("Tool already registered: " + name);
}
// Validate definition
if (!definition.description) throw new Error("Tool " + name + " missing description");
if (!definition.inputSchema) throw new Error("Tool " + name + " missing inputSchema");
if (!definition.handler) throw new Error("Tool " + name + " missing handler");
this.tools[name] = definition;
};
ToolRegistry.prototype.list = function() {
var self = this;
return Object.keys(this.tools).map(function(name) {
var tool = self.tools[name];
return {
name: name,
description: tool.description,
inputSchema: tool.inputSchema
};
});
};
ToolRegistry.prototype.call = function(name, args) {
var tool = this.tools[name];
if (!tool) {
throw new Error("Unknown tool: " + name);
}
return tool.handler(args);
};
module.exports = ToolRegistry;
Using the Registry
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var ToolRegistry = require("./tool-registry");
var registry = new ToolRegistry();
// Register tools from separate modules
require("./tools/database")(registry);
require("./tools/filesystem")(registry);
require("./tools/api-client")(registry);
require("./tools/formatting")(registry);
var server = new Server(
{ name: "multi-tool-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", function() {
return { tools: registry.list() };
});
server.setRequestHandler("tools/call", function(request) {
return registry.call(request.params.name, request.params.arguments);
});
var transport = new StdioTransport();
server.connect(transport);
Tool Module Example
// tools/database.js
var pg = require("pg");
module.exports = function(registry) {
var pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 5
});
registry.register("db-query", {
description: "Execute a read-only SQL query",
inputSchema: {
type: "object",
properties: {
sql: { type: "string", description: "SELECT query to execute" },
params: { type: "array", items: { type: "string" }, description: "Query parameters" }
},
required: ["sql"]
},
handler: function(args) {
var normalized = args.sql.trim().toUpperCase();
if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) {
return {
content: [{ type: "text", text: "Only SELECT queries are allowed" }],
isError: true
};
}
return pool.query(args.sql, args.params || [])
.then(function(result) {
return {
content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }]
};
});
}
});
registry.register("db-tables", {
description: "List all database tables",
inputSchema: { type: "object", properties: {} },
handler: function() {
return pool.query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
).then(function(result) {
return {
content: [{ type: "text", text: result.rows.map(function(r) { return r.table_name; }).join("\n") }]
};
});
}
});
registry.register("db-describe", {
description: "Describe a table's schema",
inputSchema: {
type: "object",
properties: { table: { type: "string" } },
required: ["table"]
},
handler: function(args) {
if (!/^[a-zA-Z_]\w*$/.test(args.table)) {
return { content: [{ type: "text", text: "Invalid table name" }], isError: true };
}
return pool.query(
"SELECT column_name, data_type, is_nullable FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position",
[args.table]
).then(function(result) {
return { content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }] };
});
}
});
};
Pattern 2: Shared Context
Tools often need shared state — database connections, configuration, cached data. Use a context object.
// context.js
function ServerContext(config) {
this.config = config;
this.connections = {};
this.cache = {};
this.stats = { toolCalls: 0, errors: 0, startTime: Date.now() };
}
ServerContext.prototype.getDb = function() {
if (!this.connections.db) {
var pg = require("pg");
this.connections.db = new pg.Pool({
connectionString: this.config.databaseUrl,
max: this.config.dbPoolSize || 5
});
}
return this.connections.db;
};
ServerContext.prototype.getCache = function(key) {
var entry = this.cache[key];
if (entry && Date.now() < entry.expiresAt) {
return entry.value;
}
delete this.cache[key];
return null;
};
ServerContext.prototype.setCache = function(key, value, ttlMs) {
this.cache[key] = {
value: value,
expiresAt: Date.now() + (ttlMs || 60000)
};
};
ServerContext.prototype.recordCall = function(toolName, success) {
this.stats.toolCalls++;
if (!success) this.stats.errors++;
};
ServerContext.prototype.destroy = function() {
var self = this;
Object.keys(this.connections).forEach(function(key) {
if (self.connections[key].end) {
self.connections[key].end();
}
});
};
module.exports = ServerContext;
Tools Using Shared Context
// tools/database.js — context-aware version
module.exports = function(registry, context) {
registry.register("db-query", {
description: "Execute a read-only SQL query",
inputSchema: {
type: "object",
properties: {
sql: { type: "string" },
params: { type: "array", items: { type: "string" } }
},
required: ["sql"]
},
handler: function(args) {
var pool = context.getDb();
// Check cache for repeated queries
var cacheKey = "query:" + args.sql + ":" + JSON.stringify(args.params || []);
var cached = context.getCache(cacheKey);
if (cached) {
context.recordCall("db-query", true);
return { content: [{ type: "text", text: "(cached) " + cached }] };
}
return pool.query(args.sql, args.params || [])
.then(function(result) {
var text = JSON.stringify(result.rows, null, 2);
context.setCache(cacheKey, text, 30000); // Cache for 30s
context.recordCall("db-query", true);
return { content: [{ type: "text", text: text }] };
})
.catch(function(err) {
context.recordCall("db-query", false);
return { content: [{ type: "text", text: "Error: " + err.message }], isError: true };
});
}
});
};
// tools/stats.js — uses shared context for monitoring
module.exports = function(registry, context) {
registry.register("server-stats", {
description: "Get server statistics",
inputSchema: { type: "object", properties: {} },
handler: function() {
var uptime = Math.round((Date.now() - context.stats.startTime) / 1000);
var cacheSize = Object.keys(context.cache).length;
return {
content: [{
type: "text",
text: JSON.stringify({
uptime: uptime + "s",
totalCalls: context.stats.toolCalls,
errors: context.stats.errors,
cacheEntries: cacheSize,
memoryUsage: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB"
}, null, 2)
}]
};
}
});
};
Pattern 3: Tool Middleware
Add cross-cutting concerns like logging, validation, and rate limiting without modifying individual tools.
// middleware.js
// Logging middleware
function withLogging(handler, toolName) {
return function(args) {
var start = Date.now();
console.error("[" + new Date().toISOString() + "] CALL " + toolName + " " + JSON.stringify(args).substring(0, 200));
var result = handler(args);
return Promise.resolve(result).then(function(res) {
var duration = Date.now() - start;
var isError = res.isError ? " ERROR" : "";
console.error("[" + new Date().toISOString() + "] DONE " + toolName + " " + duration + "ms" + isError);
return res;
}).catch(function(err) {
var duration = Date.now() - start;
console.error("[" + new Date().toISOString() + "] FAIL " + toolName + " " + duration + "ms: " + err.message);
throw err;
});
};
}
// Rate limiting middleware
function withRateLimit(handler, maxCallsPerMinute) {
var calls = [];
return function(args) {
var now = Date.now();
calls = calls.filter(function(t) { return now - t < 60000; });
if (calls.length >= maxCallsPerMinute) {
return {
content: [{ type: "text", text: "Rate limited. Max " + maxCallsPerMinute + " calls per minute." }],
isError: true
};
}
calls.push(now);
return handler(args);
};
}
// Timeout middleware
function withTimeout(handler, timeoutMs) {
return function(args) {
return new Promise(function(resolve, reject) {
var timer = setTimeout(function() {
reject(new Error("Tool execution timed out after " + timeoutMs + "ms"));
}, timeoutMs);
Promise.resolve(handler(args))
.then(function(result) {
clearTimeout(timer);
resolve(result);
})
.catch(function(err) {
clearTimeout(timer);
reject(err);
});
});
};
}
// Error wrapping middleware
function withErrorHandling(handler, toolName) {
return function(args) {
return Promise.resolve().then(function() {
return handler(args);
}).catch(function(err) {
console.error("Tool error [" + toolName + "]:", err.message);
return {
content: [{
type: "text",
text: "Error in " + toolName + ": " + err.message
}],
isError: true
};
});
};
}
module.exports = {
withLogging: withLogging,
withRateLimit: withRateLimit,
withTimeout: withTimeout,
withErrorHandling: withErrorHandling
};
Applying Middleware to the Registry
var middleware = require("./middleware");
// Enhanced registry with automatic middleware
function ToolRegistry(options) {
this.tools = {};
this.options = options || {};
}
ToolRegistry.prototype.register = function(name, definition) {
var handler = definition.handler;
// Apply middleware stack
handler = middleware.withErrorHandling(handler, name);
handler = middleware.withTimeout(handler, this.options.timeout || 30000);
handler = middleware.withLogging(handler, name);
if (definition.rateLimit) {
handler = middleware.withRateLimit(handler, definition.rateLimit);
}
this.tools[name] = {
description: definition.description,
inputSchema: definition.inputSchema,
handler: handler
};
};
Pattern 4: Tool Groups with Namespacing
Organize tools into logical groups with namespace prefixes.
// tool-group.js
function ToolGroup(namespace) {
this.namespace = namespace;
this.tools = {};
}
ToolGroup.prototype.add = function(name, definition) {
var fullName = this.namespace + "." + name;
this.tools[fullName] = definition;
return this;
};
ToolGroup.prototype.getTools = function() {
return this.tools;
};
// Usage
var dbGroup = new ToolGroup("db");
dbGroup.add("query", {
description: "[Database] Execute a SQL query",
inputSchema: { type: "object", properties: { sql: { type: "string" } }, required: ["sql"] },
handler: function(args) { /* ... */ }
});
dbGroup.add("tables", {
description: "[Database] List tables",
inputSchema: { type: "object", properties: {} },
handler: function() { /* ... */ }
});
var fileGroup = new ToolGroup("fs");
fileGroup.add("read", {
description: "[Filesystem] Read a file",
inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
handler: function(args) { /* ... */ }
});
fileGroup.add("list", {
description: "[Filesystem] List directory contents",
inputSchema: { type: "object", properties: { path: { type: "string" } } },
handler: function(args) { /* ... */ }
});
// Register all groups
[dbGroup, fileGroup].forEach(function(group) {
var tools = group.getTools();
Object.keys(tools).forEach(function(name) {
registry.register(name, tools[name]);
});
});
// Tools are now: db.query, db.tables, fs.read, fs.list
Pattern 5: Dynamic Tool Registration
Register tools at runtime based on configuration or discovered capabilities.
// Dynamic tools based on database tables
function registerTableTools(registry, context) {
var pool = context.getDb();
return pool.query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
).then(function(result) {
result.rows.forEach(function(row) {
var tableName = row.table_name;
// Generate a search tool for each table
registry.register("search-" + tableName, {
description: "Search the " + tableName + " table",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search term" },
limit: { type: "number", description: "Max results (default: 20)" }
},
required: ["query"]
},
handler: function(args) {
var limit = Math.min(args.limit || 20, 100);
// Get text columns for this table
return pool.query(
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = $1 " +
"AND data_type IN ('text', 'character varying')",
[tableName]
).then(function(colResult) {
var textColumns = colResult.rows.map(function(r) { return r.column_name; });
if (textColumns.length === 0) {
return { content: [{ type: "text", text: "No searchable text columns in " + tableName }] };
}
var conditions = textColumns.map(function(col) {
return col + " ILIKE $1";
}).join(" OR ");
return pool.query(
"SELECT * FROM public." + tableName + " WHERE " + conditions + " LIMIT " + limit,
["%" + args.query + "%"]
);
}).then(function(searchResult) {
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
query: args.query,
results: searchResult.rows,
count: searchResult.rows.length
}, null, 2)
}]
};
});
}
});
});
console.error("Registered search tools for " + result.rows.length + " tables");
});
}
Complete Working Example: Project Management MCP Server
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var fs = require("fs");
var path = require("path");
var https = require("https");
// ============================================================
// Project Management MCP Server
// Multi-tool server for managing software projects
// Demonstrates registry, context, middleware, and grouping
// ============================================================
// ---- Context ----
var context = {
projectDir: process.env.PROJECT_DIR || process.cwd(),
cache: {},
stats: { calls: 0, startTime: Date.now() }
};
// ---- Registry ----
var tools = {};
function register(name, def) {
// Auto-wrap with error handling and logging
var originalHandler = def.handler;
def.handler = function(args) {
context.stats.calls++;
var start = Date.now();
return Promise.resolve().then(function() {
return originalHandler(args);
}).then(function(result) {
console.error("[" + (Date.now() - start) + "ms] " + name);
return result;
}).catch(function(err) {
console.error("[ERROR] " + name + ": " + err.message);
return { content: [{ type: "text", text: "Error: " + err.message }], isError: true };
});
};
tools[name] = def;
}
// ---- File Tools ----
register("file.read", {
description: "Read a file from the project",
inputSchema: {
type: "object",
properties: { path: { type: "string", description: "Relative file path" } },
required: ["path"]
},
handler: function(args) {
var fullPath = path.resolve(context.projectDir, args.path);
if (!fullPath.startsWith(path.resolve(context.projectDir))) {
throw new Error("Path outside project directory");
}
var content = fs.readFileSync(fullPath, "utf8");
return { content: [{ type: "text", text: content }] };
}
});
register("file.write", {
description: "Write content to a file in the project",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Relative file path" },
content: { type: "string", description: "File content" }
},
required: ["path", "content"]
},
handler: function(args) {
var fullPath = path.resolve(context.projectDir, args.path);
if (!fullPath.startsWith(path.resolve(context.projectDir))) {
throw new Error("Path outside project directory");
}
var dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fullPath, args.content);
return { content: [{ type: "text", text: "Written " + args.content.length + " bytes to " + args.path }] };
}
});
register("file.search", {
description: "Search for files matching a pattern",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Search pattern (glob-like)" },
content: { type: "string", description: "Search within file contents" }
}
},
handler: function(args) {
var results = [];
function walk(dir) {
var entries = fs.readdirSync(dir, { withFileTypes: true });
entries.forEach(function(entry) {
if (entry.name.startsWith(".") || entry.name === "node_modules") return;
var fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else {
var rel = path.relative(context.projectDir, fullPath);
if (args.pattern && rel.indexOf(args.pattern) === -1 && entry.name.indexOf(args.pattern) === -1) return;
if (args.content) {
var content = fs.readFileSync(fullPath, "utf8");
if (content.indexOf(args.content) === -1) return;
}
results.push(rel);
}
});
}
walk(context.projectDir);
return { content: [{ type: "text", text: results.join("\n") || "No matches found" }] };
}
});
// ---- Git Tools ----
register("git.status", {
description: "Get git status of the project",
inputSchema: { type: "object", properties: {} },
handler: function() {
var exec = require("child_process").execSync;
var output = exec("git status --porcelain", { cwd: context.projectDir, encoding: "utf8" });
return { content: [{ type: "text", text: output || "(clean working directory)" }] };
}
});
register("git.log", {
description: "Get recent git commits",
inputSchema: {
type: "object",
properties: { count: { type: "number", description: "Number of commits (default: 10)" } }
},
handler: function(args) {
var count = Math.min(args.count || 10, 50);
var exec = require("child_process").execSync;
var output = exec("git log --oneline -" + count, { cwd: context.projectDir, encoding: "utf8" });
return { content: [{ type: "text", text: output }] };
}
});
register("git.diff", {
description: "Show git diff for a file",
inputSchema: {
type: "object",
properties: { file: { type: "string", description: "File path (optional, all changes if omitted)" } }
},
handler: function(args) {
var exec = require("child_process").execSync;
var cmd = args.file ? "git diff -- " + args.file : "git diff";
var output = exec(cmd, { cwd: context.projectDir, encoding: "utf8" });
return { content: [{ type: "text", text: output || "(no changes)" }] };
}
});
// ---- Project Tools ----
register("project.structure", {
description: "Get the project directory structure",
inputSchema: {
type: "object",
properties: { depth: { type: "number", description: "Max depth (default: 3)" } }
},
handler: function(args) {
var maxDepth = args.depth || 3;
var lines = [];
function walk(dir, indent, depth) {
if (depth > maxDepth) return;
var entries = fs.readdirSync(dir, { withFileTypes: true });
entries.sort(function(a, b) {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name);
});
entries.forEach(function(entry) {
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") return;
lines.push(indent + (entry.isDirectory() ? entry.name + "/" : entry.name));
if (entry.isDirectory()) walk(path.join(dir, entry.name), indent + " ", depth + 1);
});
}
walk(context.projectDir, "", 0);
return { content: [{ type: "text", text: lines.join("\n") }] };
}
});
register("project.dependencies", {
description: "List project dependencies from package.json",
inputSchema: { type: "object", properties: {} },
handler: function() {
var pkgPath = path.join(context.projectDir, "package.json");
if (!fs.existsSync(pkgPath)) {
return { content: [{ type: "text", text: "No package.json found" }] };
}
var pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
return {
content: [{
type: "text",
text: JSON.stringify({
name: pkg.name,
version: pkg.version,
dependencies: pkg.dependencies || {},
devDependencies: pkg.devDependencies || {}
}, null, 2)
}]
};
}
});
// ---- Server Stats ----
register("server.stats", {
description: "Get MCP server statistics",
inputSchema: { type: "object", properties: {} },
handler: function() {
return {
content: [{
type: "text",
text: JSON.stringify({
uptime: Math.round((Date.now() - context.stats.startTime) / 1000) + "s",
toolCalls: context.stats.calls,
availableTools: Object.keys(tools).length,
memoryMB: Math.round(process.memoryUsage().heapUsed / 1048576)
}, null, 2)
}]
};
}
});
// ---- Server Setup ----
var server = new Server(
{ name: "project-manager", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", function() {
return {
tools: Object.keys(tools).map(function(name) {
return {
name: name,
description: tools[name].description,
inputSchema: tools[name].inputSchema
};
})
};
});
server.setRequestHandler("tools/call", function(request) {
var tool = tools[request.params.name];
if (!tool) throw new Error("Unknown tool: " + request.params.name);
return tool.handler(request.params.arguments || {});
});
var transport = new StdioTransport();
server.connect(transport).then(function() {
console.error("Project Management MCP Server");
console.error(" Project: " + context.projectDir);
console.error(" Tools: " + Object.keys(tools).length);
console.error(" Available: " + Object.keys(tools).join(", "));
});
Common Issues & Troubleshooting
Tool Name Conflicts Between Groups
When multiple modules register tools with the same name, the second registration silently overwrites the first. The registry pattern catches this:
if (this.tools[name]) {
throw new Error("Tool already registered: " + name + ". Use namespacing to avoid conflicts.");
}
Shared Context Causes Memory Leaks
Caches without TTL or size limits grow unbounded:
// Add periodic cleanup
setInterval(function() {
var now = Date.now();
var cleaned = 0;
Object.keys(context.cache).forEach(function(key) {
if (context.cache[key].expiresAt < now) {
delete context.cache[key];
cleaned++;
}
});
if (cleaned > 0) console.error("Cache cleanup: removed " + cleaned + " entries");
}, 60000);
Middleware Stack Order Matters
Rate limiting should be checked before expensive operations, but logging should wrap everything:
// Correct order (outermost applied first):
// logging → rate-limit → timeout → error-handling → actual handler
handler = withErrorHandling(handler, name);
handler = withTimeout(handler, 30000);
handler = withRateLimit(handler, 60);
handler = withLogging(handler, name);
Dynamic Tools Not Available After Server Restart
Tools registered dynamically from database queries are not available until the async registration completes. Ensure the server waits:
// Wait for dynamic registration before accepting connections
registerDynamicTools(registry, context)
.then(function() {
return server.connect(transport);
})
.then(function() {
console.error("Server ready with " + Object.keys(tools).length + " tools");
});
Best Practices
- Use a tool registry, not a switch statement — The registry pattern keeps each tool isolated, testable, and easy to add or remove.
- Share state through an explicit context object — Do not use module-level globals. An explicit context makes dependencies visible and testable.
- Apply middleware for cross-cutting concerns — Logging, rate limiting, timeout, and error handling should not be copy-pasted into every tool handler.
- Namespace tools by domain —
db.query,fs.read,git.statusis clearer thanquery,read,status. Namespacing prevents conflicts and helps models understand tool purposes. - Validate tool inputs at the schema level — Use JSON Schema's
required,enum,minimum, andpatternfields so invalid inputs are rejected before reaching your handler. - Keep individual tools focused — A tool that queries a database AND sends an email is doing too much. Split into
db.queryandemail.sendand let the model compose them. - Register dynamic tools at startup, not on each request — Tool lists should be stable during a session. If tools change, send a
notifications/tools/list_changednotification. - Test tools independently — Each tool module should have its own test file that exercises the handler directly, without the MCP protocol layer.