Building MCP Servers in TypeScript That Don\'t Fall Apart
Building MCP Servers in TypeScript That Don\'t Fall Apart Your MCP server works perfectly when it has three tools. By the twelfth tool it turns into a pile of...
Building MCP Servers in TypeScript That Don't Fall Apart
Your MCP server works perfectly when it has three tools. By the twelfth tool it turns into a pile of switch statements you’re afraid to touch. Here is a TypeScript architecture that lets you keep your code clean as it grows — borrowed directly from Domain-Driven Design.
The Problem of Flat MCP Servers
Most MCP server tutorials start out something like this:
import { Server } from \"@modelcontextprotocol/sdk/server/index.js\";
import { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";
const server = new Server(
{
name: \"my-server\",
version: \"1.0.0\"
},
{
capabilities: {
tools: {}
}
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: \"get_user\",
description: \"Get a user by ID\",
inputSchema: { ... }
},
{
name: \"create_order\",
description: \"Create an order\",
inputSchema: { ... }
},
{
name: \"send_email\",
description: \"Send an email\",
inputSchema: { ... }
},
{
name: \"get_product\",
description: \"Get product details\",
inputSchema: { ... }
},
// ...8 more tools
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case \"get_user\":
return handleGetUser(request.params.arguments);
case \"create_order\":
return handleCreateOrder(request.params.arguments);
case \"send_email\":
return handleSendEmail(request.params.arguments);
// ...
}
});
Look familiar? The problem isn’t that this code is wrong. It works perfectly — for a single developer maintaining it for three weeks.
As soon as you hit real production requirements — different ownership of user and order logic, different authentication contexts, resource state — you need structure.
This is exactly where Domain-Driven Design (DDD) proves its value.
Three DDD Concepts and Their Mapping to MCP
You don’t need full DDD immersion to get the benefits. Here are three key ideas and how they map onto MCP servers:
1. Entities and Value Objects
In DDD, Entities have identity and lifecycle (e.g., a user). Value Objects are immutable and describe a characteristic (e.g., an address).
In the MCP context this means:
// Entity: User
interface User {
id: string;
email: string;
createdAt: Date;
}
// Value objects
interface Address {
street: string;
city: string;
zipCode: string;
}
interface UserPreferences {
theme: \'light\' | \'dark\';
notifications: boolean;
}
2. Aggregates
An aggregate is a cluster of related objects that we treat as a single unit for changes.
Example with an order:
interface Order {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
totalAmount: number;
}
interface OrderItem {
productId: string;
quantity: number;
unitPrice: number;
}
type OrderStatus = \'pending\' | \'confirmed\' | \'shipped\' | \'delivered\';
3. Domain Services
When an operation doesn’t belong naturally to a single entity, you create a service.
Example:
class OrderService {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private paymentService: PaymentService
) {}
async placeOrder(userId: string, items: CartItem[]): Promise {
// Check availability
const available = await this.inventoryService.checkAvailability(items);
if (!available) {
throw new Error(\"Some items are out of stock\");
}
// Create the order
const order = await this.orderRepository.create(userId, items);
// Process payment
await this.paymentService.process(order);
return order;
}
}
Restructuring an MCP Server with DDD
Let’s transform our flat server into a modular structure:
// src/infrastructure/mcp/mcp-server.ts
import { Server } from \"@modelcontextprotocol/sdk/server/index.js\";
import { ToolRegistry } from \"./tool-registry.js\";
export class McpServer {
private server: Server;
private toolRegistry: ToolRegistry;
constructor(
private userHandlers: UserHandlers,
private orderHandlers: OrderHandlers,
private emailHandlers: EmailHandlers
) {
this.server = new Server(
{ name: \"my-server\", version: \"1.0.0\" },
{ capabilities: { tools: {} } }
);
this.toolRegistry = new ToolRegistry();
this.setupHandlers();
}
private setupHandlers() {
// Register tools
this.server.setRequestHandler(
ListToolsRequestSchema,
async () => this.toolRegistry.listTools()
);
// Route tool requests
this.server.setRequestHandler(
CallToolRequestSchema,
async (request) => this.toolRegistry.handle(request)
);
}
}
// src/infrastructure/mcp/tool-registry.ts
export class ToolRegistry {
private tools: Tool[] = [];
private handlers: Map = new Map();
registerTool(tool: Tool, handler: ToolHandler) {
this.tools.push(tool);
this.handlers.set(tool.name, handler);
}
async listTools(): Promise {
return { tools: this.tools };
}
async handle(request: CallToolRequest): Promise {
const handler = this.handlers.get(request.params.name);
if (!handler) {
throw new Error(`Unknown tool: ${request.params.name}`);
}
return handler(request.params.arguments);
}
}
Project Modular Structure
// src/users/user.ts (Entities and Value Objects)
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
export interface UserPreferences {
theme: \'light\' | \'dark\';
language: string;
timezone: string;
}
// src/users/user-service.ts (Domain service)
export class UserService {
constructor(
private userRepository: UserRepository,
private emailService: EmailService
) {}
async getUser(userId: string): Promise {
return this.userRepository.findById(userId);
}
async createUser(userData: CreateUserRequest): Promise {
const user = await this.userRepository.create(userData);
await this.emailService.sendWelcomeEmail(user.email);
return user;
}
}
// src/users/mcp-handlers.ts (MCP adapter)
export class UserHandlers {
constructor(
private userService: UserService,
private toolRegistry: ToolRegistry
) {
this.registerToolHandlers();
}
private registerToolHandlers() {
this.toolRegistry.registerTool(
{
name: \"get_user\",
description: \"Get user details by ID\",
inputSchema: {
type: \"object\",
properties: {
userId: {
type: \"string\",
description: \"User ID to retrieve\"
}
}
}
},
async (args) => {
const user = await this.userService.getUser(args.userId);
return {
content: [
{
type: \"text\",
text: JSON.stringify(user)
}
]
};
}
);
this.toolRegistry.registerTool(
{
name: \"create_user\",
description: \"Create new user\",
inputSchema: {
type: \"object\",
properties: {
email: {
type: \"string\",
description: \"User email address\"
},
name: {
type: \"string\",
description: \"User name\"
}
}
}
},
async (args) => {
const user = await this.userService.createUser({
email: args.email,
name: args.name
});
return {
content: [
{
type: \"text\",
text: JSON.stringify(user)
}
]
};
}
);
}
}
Practical Structure Tips
1. Layer by Layer
src/
├── domain/ # Entities, Value Objects, Domain Services (no framework dependencies)
├── application/ # Use cases – application services
├── infrastructure/ # Repository implementations, external APIs, MCP adapters
└── presentation/ # MCP servers, HTTP controllers if needed
2. Single Responsibility for Tools
// src/users/mcp-handlers.ts - responsible only for users
export class UserHandlers {
// ...
}
// src/orders/mcp-handlers.ts - responsible only for orders
export class OrderHandlers {
// ...
}
// src/emails/mcp-handlers.ts - responsible only for emails
export class EmailHandlers {
// ...
}
3. Implementing Repositories for Testing
export interface UserRepository {
findById(id: string): Promise;
create(data: CreateUserRequest): Promise;
save(user: User): Promise;
}
export class InMemoryUserRepository implements UserRepository {
private users: Map = new Map();
async findById(id: string): Promise {
return this.users.get(id) || null;
}
async create(data: CreateUserRequest): Promise {
const user = {
id: uuid(),
email: data.email,
name: data.name,
createdAt: new Date()
};
this.users.set(user.id, user);
return user;
}
async save(user: User): Promise {
this.users.set(user.id, user);
}
}
export class PostgresUserRepository implements UserRepository {
constructor(private pool: Pool) {}
async findById(id: string): Promise {
const result = await this.pool.query(
\'SELECT * FROM users WHERE id = $1\',
[id]
);
return result.rows[0] || null;
}
// ...
}
4. Setting Up Dependencies with a DI Container
import { container } from \'tsyringe\';
// Register dependencies
container.register(\'UserRepository\', {
useClass: process.env.NODE_ENV === \'test\'
? InMemoryUserRepository
: PostgresUserRepository
});
container.register(\'UserService\', {
useClass: UserService
});
// Clean resolution in the server
export class McpServer {
constructor() {
const userService = container.resolve(\'UserService\');
const userHandlers = new UserHandlers(userService);
// ...
}
}
5. Logging and Error Handling in DDD Structure
export class UserHandlers {
constructor(
private userService: UserService,
private logger: Logger,
private toolRegistry: ToolRegistry
) {
this.registerToolHandlers();
}
private registerToolHandlers() {
this.toolRegistry.registerTool(
{ name: \"get_user\", /* ... */ },
async (args) => {
try {
this.logger.info(\'Getting user\', { userId: args.userId });
const user = await this.userService.getUser(args.userId);
if (!user) {
throw new NotFoundError(`User ${args.userId} not found`);
}
return {
content: [{ type: \"text\", text: JSON.stringify(user) }]
};
} catch (error) {
this.logger.error(\'Error getting user\', { error });
throw error;
}
}
);
}
}
What You Get
This structure gives you:
- Clear separation of concerns: different team members can work on different modules.
- Testability: domain modules don't depend on external frameworks; you can easily test business logic in isolation.
- Evolvability: adding a new MCP tool means adding a new method to a domain service, not rewriting a central
switch. - Easy maintenance: when you start a new server instance or add a tool, you know where to look for the code.
As far as your clients are concerned, when their MCP model calls your server, they only see tools and schemas. They don't care whether you have modular structure or a giant switch under the hood.
But for you, the developer adding the thirteenth tool, it makes a huge difference.