MCP Server Design Patterns

Proven design patterns for building maintainable, scalable MCP servers with mcp-framework and the official TypeScript SDK.


title: "MCP Server Design Patterns" description: "Proven design patterns for building maintainable, scalable MCP servers with mcp-framework and the official TypeScript SDK." order: 1 keywords: ["MCP design patterns", "MCP server architecture", "MCP best practices", "MCP patterns"] date: "2026-04-01"

Quick Summary

Well-structured MCP servers are easier to maintain, test, and extend. This guide covers five proven design patterns — single responsibility, tool composition, factory, middleware, and repository — that keep your codebase clean as it grows. Every pattern includes concrete examples for both the official TypeScript SDK (@modelcontextprotocol/sdk) and mcp-framework.

5design patterns every MCP server developer should know

Why Design Patterns Matter for MCP

MCP servers tend to grow quickly. What starts as a single tool often becomes ten tools, five resources, and a handful of prompts. Without deliberate structure, the codebase becomes a tangled monolith that is hard to test and risky to change.

Design Pattern

A design pattern is a reusable solution to a commonly occurring problem in software design. In the context of MCP servers, patterns help you organize tool handlers, manage dependencies, and keep your server modular as it grows.

1. Single Responsibility Pattern

Each tool, resource, or prompt should do exactly one thing. When a tool starts accumulating if branches for different operations, split it into multiple focused tools.

One Tool, One Job

Never create a single tool that performs multiple unrelated operations selected by a "mode" or "action" parameter. AI models choose tools by reading their names and descriptions. A tool called manage-database with an action parameter is harder for the model to use correctly than three separate tools: query-database, insert-record, and delete-record.

Bad: Multi-purpose tool

// Avoid: one tool trying to do everything
server.tool(
  "database",
  "Perform database operations",
  {
    action: z.enum(["query", "insert", "delete"]),
    table: z.string(),
    data: z.unknown().optional(),
  },
  async ({ action, table, data }) => {
    switch (action) {
      case "query": /* ... */
      case "insert": /* ... */
      case "delete": /* ... */
    }
  }
);

Good: Focused tools

// Better: each tool has a clear purpose
server.tool(
  "query-database",
  "Run a read-only SQL query and return results",
  { sql: z.string().describe("SQL SELECT query") },
  async ({ sql }) => { /* ... */ }
);

server.tool(
  "insert-record",
  "Insert a new record into a table",
  {
    table: z.string().describe("Target table name"),
    data: z.record(z.unknown()).describe("Column values"),
  },
  async ({ table, data }) => { /* ... */ }
);

2. Tool Composition Pattern

Complex operations often require coordinating multiple smaller operations. Instead of duplicating logic across tools, extract shared functionality into composable helper functions.

Compose, Don't Duplicate

When two or more tools share the same logic — database connections, API calls, data formatting — extract that logic into a shared utility function. This prevents drift between tools and makes updates a single-point change.

// Shared composable utilities
async function withDatabaseConnection<T>(
  fn: (db: Database) => Promise<T>
): Promise<T> {
  const db = await pool.getConnection();
  try {
    return await fn(db);
  } finally {
    db.release();
  }
}

function formatAsTable(rows: Record<string, unknown>[]): string {
  if (rows.length === 0) return "No results found.";
  const headers = Object.keys(rows[0]);
  const lines = rows.map(row =>
    headers.map(h => String(row[h] ?? "")).join(" | ")
  );
  return [headers.join(" | "), "-".repeat(40), ...lines].join("\n");
}

// Tools compose these utilities
server.tool("query-database", "Run a SQL query", {
  sql: z.string(),
}, async ({ sql }) => {
  const rows = await withDatabaseConnection(db => db.query(sql));
  return { content: [{ type: "text", text: formatAsTable(rows) }] };
});

server.tool("describe-table", "Show table schema", {
  table: z.string(),
}, async ({ table }) => {
  const cols = await withDatabaseConnection(db => db.describe(table));
  return { content: [{ type: "text", text: formatAsTable(cols) }] };
});

3. Factory Pattern for Tools

When you have many tools that follow the same structure, use a factory function to generate them. This is especially useful for CRUD operations across multiple entities.

Use Factories for Repetitive Tools

If you find yourself copying and pasting tool definitions that differ only in the entity name and schema, a factory function eliminates the duplication. Define the pattern once and generate tools for each entity.

With the official TypeScript SDK

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z, ZodRawShape } from "zod";

function createCrudTools<T extends ZodRawShape>(
  server: McpServer,
  entity: string,
  schema: T,
  repository: {
    findById: (id: string) => Promise<unknown>;
    create: (data: unknown) => Promise<unknown>;
    update: (id: string, data: unknown) => Promise<unknown>;
    remove: (id: string) => Promise<void>;
  }
) {
  server.tool(`get-${entity}`, `Get a ${entity} by ID`, {
    id: z.string().describe(`${entity} ID`),
  }, async ({ id }) => {
    const item = await repository.findById(id);
    if (!item) {
      return { content: [{ type: "text", text: `${entity} not found: ${id}` }], isError: true };
    }
    return { content: [{ type: "text", text: JSON.stringify(item, null, 2) }] };
  });

  server.tool(`create-${entity}`, `Create a new ${entity}`, schema, async (data) => {
    const created = await repository.create(data);
    return { content: [{ type: "text", text: JSON.stringify(created, null, 2) }] };
  });

  server.tool(`delete-${entity}`, `Delete a ${entity} by ID`, {
    id: z.string().describe(`${entity} ID`),
  }, async ({ id }) => {
    await repository.remove(id);
    return { content: [{ type: "text", text: `${entity} ${id} deleted successfully.` }] };
  });
}

// Usage
createCrudTools(server, "user", {
  name: z.string().describe("Full name"),
  email: z.string().email().describe("Email address"),
}, userRepository);

createCrudTools(server, "project", {
  title: z.string().describe("Project title"),
  status: z.enum(["active", "archived"]).describe("Project status"),
}, projectRepository);

With mcp-framework

import { MCPTool } from "mcp-framework";
import { z } from "zod";

function createGetTool(entity: string, repository: any) {
  return class extends MCPTool<{ id: string }> {
    name = `get-${entity}`;
    description = `Get a ${entity} by ID`;
    schema = {
      id: { type: z.string(), description: `${entity} ID` },
    };

    async execute({ id }: { id: string }) {
      const item = await repository.findById(id);
      if (!item) throw new Error(`${entity} not found: ${id}`);
      return JSON.stringify(item, null, 2);
    }
  };
}

4. Middleware Pattern

Wrap tool handlers with cross-cutting concerns like logging, timing, authentication, and error handling. This keeps individual tool handlers focused on business logic.

Centralize Cross-Cutting Concerns

Authentication checks, request logging, performance timing, and error wrapping should not be duplicated in every tool handler. Use a middleware wrapper to apply these concerns uniformly. This guarantees consistency and makes it easy to add new concerns later.

type ToolHandler<T> = (args: T) => Promise<{
  content: { type: string; text: string }[];
  isError?: boolean;
}>;

function withMiddleware<T>(
  toolName: string,
  handler: ToolHandler<T>
): ToolHandler<T> {
  return async (args: T) => {
    const start = Date.now();

    // Logging
    console.error(`[${toolName}] Called with:`, JSON.stringify(args));

    try {
      const result = await handler(args);
      const duration = Date.now() - start;
      console.error(`[${toolName}] Completed in ${duration}ms`);
      return result;
    } catch (error) {
      const duration = Date.now() - start;
      console.error(`[${toolName}] Failed after ${duration}ms:`, error);

      return {
        content: [{
          type: "text",
          text: `Tool '${toolName}' failed: ${error instanceof Error ? error.message : "Unknown error"}`,
        }],
        isError: true,
      };
    }
  };
}

// Apply middleware to any tool
server.tool(
  "search-users",
  "Search for users by name",
  { query: z.string() },
  withMiddleware("search-users", async ({ query }) => {
    const users = await db.searchUsers(query);
    return { content: [{ type: "text", text: JSON.stringify(users, null, 2) }] };
  })
);

5. Repository Pattern for Resources

Separate data access logic from MCP resource definitions. A repository encapsulates all the logic for retrieving and transforming data, while the resource handler simply calls the repository and returns the result.

Abstract Data Access Behind Repositories

MCP resource handlers should not contain database queries, API calls, or file reads directly. Place that logic in a repository class or module. This makes it possible to swap data sources (e.g., from a database to an API) without changing the resource definitions, and makes unit testing straightforward.

// repositories/metrics-repository.ts
export class MetricsRepository {
  async getSystemMetrics() {
    const cpu = await getCpuUsage();
    const memory = await getMemoryUsage();
    const disk = await getDiskUsage();
    return { cpu, memory, disk, timestamp: new Date().toISOString() };
  }

  async getRequestMetrics(since: Date) {
    return await db.query(
      "SELECT * FROM request_metrics WHERE created_at > $1",
      [since]
    );
  }
}

// Register resources that use the repository
const metricsRepo = new MetricsRepository();

server.resource("system-metrics", "system://metrics", async (uri) => ({
  contents: [{
    uri: uri.href,
    mimeType: "application/json",
    text: JSON.stringify(await metricsRepo.getSystemMetrics(), null, 2),
  }],
}));

Combining Patterns

In practice, you combine these patterns. A production MCP server might use the factory pattern to generate CRUD tools, the middleware pattern for logging and error handling, and the repository pattern for all data access.

PatternWhen to UsePrimary Benefit
Single ResponsibilityAlways — for every tool and resourceAI models can discover and use tools more accurately
Tool CompositionWhen 2+ tools share logicEliminates duplication and drift
FactoryWhen generating similar tools for multiple entitiesDefine the pattern once, generate many tools
MiddlewareFor logging, auth, timing, error wrappingConsistent cross-cutting behavior
RepositoryFor all data access in resources and toolsSwappable data sources, easy testing
Start Simple, Refactor Into Patterns

You do not need to apply all five patterns from day one. Start with single responsibility and composition. As your server grows, introduce factories for repetitive structures, middleware for cross-cutting concerns, and repositories when data access becomes complex. Both mcp-framework and the official TypeScript SDK accommodate all of these patterns naturally.

Frequently Asked Questions