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"
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.
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.
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.
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.
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.
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.
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.
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.
| Pattern | When to Use | Primary Benefit |
|---|---|---|
| Single Responsibility | Always — for every tool and resource | AI models can discover and use tools more accurately |
| Tool Composition | When 2+ tools share logic | Eliminates duplication and drift |
| Factory | When generating similar tools for multiple entities | Define the pattern once, generate many tools |
| Middleware | For logging, auth, timing, error wrapping | Consistent cross-cutting behavior |
| Repository | For all data access in resources and tools | Swappable data sources, easy testing |
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.