Mcp

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/sdk package 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 domaindb.query, fs.read, git.status is clearer than query, 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, and pattern fields 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.query and email.send and 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_changed notification.
  • Test tools independently — Each tool module should have its own test file that exercises the handler directly, without the MCP protocol layer.

References

Powered by Contentful