Создание MCP‑серверов на TypeScript, которые не разваливаются
Ваш MCP сервер работает отлично, когда на нём три инструмента. К двенадцатому инструменту он превращается в груду `switch`-конструкций
Создание MCP-серверов на TypeScript, которые не разваливаются
Ваш MCP сервер работает отлично, когда на нём три инструмента. К двенадцатому инструменту он превращается в груду switch-конструкций, которых вы боитесь касаться. Вот архитектура на TypeScript, которая позволяет сохранить чистоту кода по мере роста — одолженная прямо из Domain-Driven Design.
Проблема плоских MCP серверов
Большинство туториалов по MCP серверам начинаются примерно так:
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 инструментов
],
}));
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);
// ...
}
});
Знакомо? Проблема не в том, что этот код неправильный. Он работает отлично — для одного разработчика, поддерживающего его в течение трёх недель.
Как только вы сталкиваетесь с реальными требованиями продакшена — разные владельцы логики пользователей и заказов, разные контексты аутентификации, состояние ресурсов — вам нужна структура.
Именно здесь Domain-Driven Design (DDD) доказывает свою ценность.
Три концепции DDD и их MCP отображение
Вам не нужно полное погружение в DDD для получения выгод. Вот три ключевые идеи и как они ложатся на MCP серверы:
1. Сущности (Entities) и Объекты-значения (Value Objects)
В DDD Сущности имеют идентификатор и жизненный цикл (например, пользователь). Объекты-значения неизменяемы и описывают характеристику (например, адрес).
В MCP контексте это означает:
// Сущность: User
interface User {
id: string;
email: string;
createdAt: Date;
}
// Объекты-значения
interface Address {
street: string;
city: string;
zipCode: string;
}
interface UserPreferences {
theme: \'light\' | \'dark\';
notifications: boolean;
}
2. Агрегаты (Aggregates)
Агрегат — это кластер из связанных объектов, которые мы рассматриваем как единое целое для изменений.
Пример с заказом:
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)
Когда операция не принадлежит естественным образом одной сущности, вы создаёте сервис.
Пример:
class OrderService {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private paymentService: PaymentService
) {}
async placeOrder(userId: string, items: CartItem[]): Promise {
// Проверка доступности
const available = await this.inventoryService.checkAvailability(items);
if (!available) {
throw new Error(\"Some items are out of stock\");
}
// Создание заказа
const order = await this.orderRepository.create(userId, items);
// Обработка платежа
await this.paymentService.process(order);
return order;
}
}
Реструктуризация MCP сервера с помощью DDD
Давайте преобразуем наш плоский сервер в модульную структуру:
// 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() {
// Регистрация инструментов
this.server.setRequestHandler(
ListToolsRequestSchema,
async () => this.toolRegistry.listTools()
);
// Маршрутизация запросов инструментов
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);
}
}
Модульная структура проекта
// src/users/user.ts (Сущности и 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 (Доменный сервис)
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 адаптер)
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)
}
]
};
}
);
}
}
Практические советы по структуре
1. Слой за слоем
src/
├── domain/ # Сущности, Value Objects, Domain Services (без зависимостей от фреймворков)
├── application/ # Сценарии использования - прикладные сервисы
├── infrastructure/ # Реализации репозиториев, внешние API, MCP адаптеры
└── presentation/ # MCP серверы, HTTP контроллеры, если нужно
2. Принцип единой ответственности для инструментов
// src/users/mcp-handlers.ts - отвечает только за пользователей
export class UserHandlers {
// ...
}
// src/orders/mcp-handlers.ts - отвечает только за заказы
export class OrderHandlers {
// ...
}
// src/emails/mcp-handlers.ts - отвечает только за email
export class EmailHandlers {
// ...
}
3. Реализация репозиториев для тестирования
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. Настройка зависимостей с помощью DI контейнера
import { container } from \'tsyringe\';
// Регистрация зависимостей
container.register(\'UserRepository\', {
useClass: process.env.NODE_ENV === \'test\'
? InMemoryUserRepository
: PostgresUserRepository
});
container.register(\'UserService\', {
useClass: UserService
});
// Красивое разрешение в сервере
export class McpServer {
constructor() {
const userService = container.resolve(\'UserService\');
const userHandlers = new UserHandlers(userService);
// ...
}
}
5. Логирование и обработка ошибок в структуре DDD
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;
}
}
);
}
}
Чего вы достигнете
Эта структура даёт вам:
- Четкую разделяемость ответственности: разные члены команды могут работать над разными модулями.
- Тестируемость: модули домена не зависят от внешних фреймворков, вы можете легко тестировать бизнес-логику изолированно.
- Эволюционируемость: добавление нового инструмента MCP означает добавление нового метода в доменный сервис, а не переписывание центрального
switch. - Простое поддержание: когда вы запускаете новый экземпляр сервера или инструмента, вы знаете, где искать код.
Как ваши клиенты, когда их модель MCP вызывает ваш сервер, они видят только инструменты и схемы. Им всё равно, что у вас лежит под капотом — модульная структура или гигантский switch.
Но для вас, разработчика, который добавляет 13-й инструмент, это имеет огромное значение.