MCP с нуля: собираем production-ready сервер на TypeScript
Построить production-сервер Model Context Protocol — это не просто прицепить пару инструментов. Паттерны проектирования схем, аутентификации, обработки ошибок, стриминга, observability и те production-реалии, которые делают MCP-серверы по-настоящему полезными на масштабе.
К середине 2026 года MCP (Model Context Protocol) — де-факто стандарт для связи LLM с инструментами. Его представил Anthropic; OpenAI, Google и более широкая экосистема его приняли. Cursor, Claude Desktop, ChatGPT, кастомные агенты — все говорят на MCP.
Если вы хотите, чтобы LLM-агенты взаимодействовали с вашим сервисом, MCP-сервер — это способ. И как только вы соберёте один-два таких сервера, вы увидите, что сам протокол невелик. Интересная инженерия — во всём, что вокруг него: проектирование схем, обработка ошибок, аутентификация, стриминг, производительность, observability.
Эта статья — глубокое погружение в построение production-grade MCP-серверов на TypeScript. Мы разберём паттерны, которые выдерживают реальное использование агентами, а не только механику самого протокола.
Что такое MCP, коротко
MCP — это клиент-серверный протокол, в котором:
- Серверы выставляют инструменты (tools), ресурсы (resources) и промпты.
- Клиенты — это обычно LLM-агенты, которые их потребляют.
Протокол использует JSON-RPC 2.0. Транспорты включают stdio (для локальных процессов) и HTTP/SSE (для удалённых). Аутентификация и безопасность — часть протокола; основные реализации поддерживают OAuth, API-ключи и аналогичные механизмы.
Задача сервера: выставить полезные возможности LLM так, чтобы те могли их найти и использовать.
Базовая структура
С официальным пакетом @modelcontextprotocol/sdk минимальный сервер на высокоуровневом API McpServer выглядит так:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
server.registerTool(
"echo",
{
description: "Echo back the provided text.",
inputSchema: { text: z.string() },
},
async ({ text }) => ({
content: [{ type: "text", text }],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);(Тот же SDK предоставляет и более низкоуровневый класс Server плюс setRequestHandler для ListToolsRequestSchema / CallToolRequestSchema, если нужен полный контроль над обработчиками запросов — но для большинства серверов McpServer.registerTool короче и его сложнее испортить.)
Это скелет. То, что вы вкладываете в обработчики инструментов — и как — и есть основная работа.
Паттерн 1: философия проектирования инструментов
Первое решение: какие инструменты вы выставляете и с какой гранулярностью?
Типичный провал: выставить свой внутренний API как набор инструментов. Если у вас 200 REST-эндпоинтов, 200 инструментов — это катастрофа. Модели с большим числом инструментов работают хуже; описания становятся неуправляемыми; протокол превращается в лабиринт.
Лучше: проектировать инструменты под то, как агенты хотят их использовать. Каждый инструмент делает одну чётко определённую вещь, принимает чётко определённые входы, возвращает чётко определённые выходы.
Несколько принципов:
Одна концепция на инструмент. Не делайте manage_customer, который делает 12 разных вещей. Сделайте search_customers, get_customer, update_customer_email, archive_customer — каждый сфокусирован.
Правильная гранулярность. Слишком мелко — агенту нужно много вызовов; слишком крупно — он не может точно сделать то, что нужно. Целевая планка — «операции, которые назвал бы человек».
Глаголы действия. search_documents, а не documents. Инструменты должны называться по тому, что они делают.
Разделение чтения и записи. Инструменты чтения безопаснее; инструменты записи имеют побочные эффекты. Различайте в именовании (list_x vs create_x) и обрабатывайте по-разному (требовать явного подтверждения, idempotency-ключей и т. д.).
Агрегируйте, когда это полезно. get_customer_profile, возвращающий клиента + недавние заказы + тикеты поддержки одним вызовом, часто лучше трёх отдельных вызовов. Агент получает контекст за один заход.
Для сервера, выставляющего, скажем, систему клиентской поддержки, разумный набор — это 8–15 инструментов. Больше — обычно перебор.
Паттерн 2: проектирование схем
У каждого инструмента есть входная схема (параметры, которые LLM должна предоставить) и выход (то, что возвращает ваш инструмент). Схемы — это не только валидация; это prompt engineering.
С использованием Zod для входных схем:
const searchCustomersSchema = z.object({
query: z.string().describe(
"Search term: name, email, or company. Be specific to avoid too many matches."
),
limit: z.number().int().min(1).max(50).default(10).describe(
"Maximum results to return. Default 10, max 50."
),
filters: z.object({
tier: z.enum(["free", "pro", "enterprise"]).optional().describe(
"Filter to specific customer tier"
),
status: z.enum(["active", "trial", "churned"]).optional().describe(
"Filter by customer status"
),
}).optional(),
});Обратите внимание:
- У каждого поля есть
.describe(). Описание — это то, что читает LLM. - Enum-ы явные. Свободно-форматные строки ограничены там, где это возможно.
- Дефолты разумные.
- Ограничения (min/max, длина) явные.
- Понятно, что обязательно, а что опционально.
Описания играют огромную роль. «search term» — бесполезно; «Search term: name, email, or company. Be specific to avoid too many matches» — полезная подсказка для LLM.
Паттерн 3: форма выхода
Выход — это то, что видит LLM и на что она реагирует. Хорошее проектирование выхода резко улучшает поведение модели.
Структурированные выходы.
type SearchResult = {
customers: Customer[];
total_matches: number;
truncated: boolean;
next_page_cursor?: string;
};С контекстом.
{
customers: [...],
total_matches: 47,
truncated: true,
next_page_cursor: "abc",
message: "Found 47 matches; showing first 10. Use next_page_cursor to get more."
}Поле message — это человекочитаемая подсказка. LLM её используют.
С аккуратной обработкой ошибок.
{
error: "ambiguous_query",
message: "Search term 'john' matched 247 customers. Please be more specific.",
suggestion: "Try including a company name or email domain.",
partial_results: [...] // top 3 by relevance, optional
}Ошибка структурирована (машиночитаемо), но содержит и сообщение, и подсказку (читаемо для LLM). Модель может адаптироваться — либо попросить пользователя уточнить, либо переформулировать запрос.
Подходящего размера.
Инструмент, возвращающий 10 000 записей, бесполезен. Контекст LLM их не вместит; даже если бы вместил, она ими толком не воспользуется. Всегда пагинируйте, обрезайте или суммируйте. Возвращайте достаточно для принятия решения, а не всё, что существует.
Паттерн 4: семантика ошибок
Инструменты падают. Как они сообщают об ошибке LLM, определяет, восстановится ли модель аккуратно или усугубит проблему.
Категории ошибок.
type ToolError =
| { type: "validation"; message: string; field?: string }
| { type: "auth"; message: string }
| { type: "not_found"; message: string; suggestion?: string }
| { type: "conflict"; message: string; resolution?: string }
| { type: "rate_limit"; message: string; retry_after_seconds: number }
| { type: "service_unavailable"; message: string; retryable: boolean }
| { type: "internal"; message: string; trace_id: string };У каждой категории своя семантика. LLM должна реагировать по-разному:
validation: исправить вход и повторить.not_found: сообщить пользователю или попробовать другой запрос.conflict: спросить, как разрешить.rate_limit: подождать и повторить.service_unavailable: попробовать фолбэк или уведомить пользователя.internal: сдаться, показать пользователю.
Документирование этого на стороне сервера делает LLM способнее.
Формат ошибок.
Возвращайте ошибки как структурированные данные, с понятными и действенными сообщениями:
{
error: {
type: "validation",
message: "The email address is not valid format.",
field: "email",
suggestion: "Provide a valid email address like 'name@example.com'."
}
}Избегайте:
{
error: "Invalid input"
}Первый вариант даёт LLM шанс на восстановление. Второй оставляет её гадать.
Паттерн 5: аутентификация и авторизация
Production MCP-серверам нужна аутентификация. Любой, кто может достучаться до сервера, может использовать инструменты. Это почти всегда проблема.
Аутентификация: кто звонит?
Типичные подходы:
- API-ключ. Просто, привычно, работает для сервис-к-сервису. Выдавайте по ключу на потребителя; периодически ротируйте.
- OAuth. Для мульти-пользовательских систем, где конечные пользователи авторизуют агентов. Сложнее, но правильный ответ для многих сценариев.
- mTLS. Для сред с высокими требованиями к безопасности. Взаимные TLS-сертификаты с обеих сторон.
Реализация зависит от транспорта. По HTTP запрос аутентифицируется до того, как он вообще дойдёт до MCP-обработчика (в вашем middleware на Express/Hono/Fastify), и вызывающий кладётся в объект запроса:
// Express-style middleware in front of the MCP HTTP endpoint.
app.use("/mcp", async (req, res, next) => {
const apiKey = req.header("x-api-key");
const caller = await authenticate(apiKey);
if (!caller) return res.status(401).send("Unauthorized");
(req as any).caller = caller;
next();
});Затем внутри каждого обработчика инструмента вызывающего берут из контекста вызова (extra), а не из сырых заголовков. Поверх stdio HTTP-заголовков нет; аутентификация обычно приходит из переменных окружения процесса или конфиг-файлов.
Авторизация: что им разрешено?
После аутентификации — какие инструменты вызывающий может использовать и на каких данных?
function authorize(caller: Caller, tool: string, params: any): boolean {
// Caller-level: can this caller use this tool at all?
if (!caller.tools.includes(tool)) return false;
// Data-level: is this caller authorized for this specific data?
if (params.tenant_id && params.tenant_id !== caller.tenant_id) return false;
return true;
}Не позволяйте LLM принимать решения об авторизации. Модель можно обмануть. Авторизация — задача сервера; LLM видит только те данные, к которым у неё есть доступ.
Для мульти-тенантных систем: каждый вызов инструмента ограничен тенантом. Тенант определяется аутентификацией, а не параметрами, которые передаёт LLM.
Паттерн 6: идемпотентность
Для операций записи идемпотентность критична. LLM может повторить вызов; может вызвать тот же инструмент дважды в разных контекстах. Без идемпотентности получите дубли.
Idempotency-ключи.
Инструмент принимает параметр idempotency_key. Сервер проверяет: видели ли мы этот ключ раньше? Если да — вернуть закэшированный результат. Если нет — выполнить и закэшировать.
async function createInvoice(params: {
amount: number;
customer_id: string;
idempotency_key: string;
}) {
const cached = await idempotencyStore.get(params.idempotency_key);
if (cached) return cached;
const invoice = await actuallyCreateInvoice(params);
await idempotencyStore.set(params.idempotency_key, invoice, { ttl: 86400 });
return invoice;
}Для LLM намекните на это в описании инструмента:
"For each unique invoice you create, generate a UUID and pass it as idempotency_key. If you need to retry the operation, use the same UUID to avoid duplicate creation."Паттерн 7: стриминг
Для инструментов, которые производят большие выходы или требуют времени, стриминг — лучший UX. MCP поддерживает progress-нотификации изнутри обработчика инструмента через аргумент extra:
server.registerTool(
"long_running_task",
{ description: "...", inputSchema: { ... } },
async (input, extra) => {
await extra.sendNotification({
method: "notifications/progress",
params: { progressToken: extra._meta?.progressToken, progress: 0, message: "Starting..." },
});
for (const step of steps) {
await doStep(step);
await extra.sendNotification({
method: "notifications/progress",
params: {
progressToken: extra._meta?.progressToken,
progress: step.index / steps.length,
message: step.name,
},
});
}
return { content: [{ type: "text", text: JSON.stringify({ result: finalResult }) }] };
}
);Используйте стриминг для:
- Долгих операций (>5 секунд).
- Больших выходов (чтобы LLM могла начать обработку, пока вывод ещё идёт).
- Операций с промежуточными результатами, которые стоит показывать.
Не стримите быстрые и простые операции — это добавляет сложности без пользы.
Паттерн 8: кэширование
Многие вызовы инструментов бьют по одним и тем же данным повторно. Кэширование может резко улучшить производительность и снизить нагрузку на бэкенд.
Локальный кэш. In-process кэш (например, LRU) для горячих данных.
Распределённый кэш. Redis или аналог для общего кэша между инстансами сервера.
Инвалидация кэша. Когда данные меняются, удаляйте релевантные записи. (Это сложная часть.)
TTL. Записи в кэше живут заданное время. Настраивайте под тип данных — профили клиентов могут жить часами; цены — минутами.
Чтобы кэширование помогло, одни и те же вызовы должны повторяться. На многих MCP-серверах это так — агенты часто обращаются к одним и тем же сущностям многократно внутри сессии.
Паттерн:
async function getCustomerCached(id: string) {
const cached = await cache.get(`customer:${id}`);
if (cached) {
metrics.increment("cache.hit");
return cached;
}
metrics.increment("cache.miss");
const customer = await db.getCustomer(id);
await cache.set(`customer:${id}`, customer, { ttl: 300 });
return customer;
}Паттерн 9: rate limiting
LLM-агенты могут быть на удивление агрессивны — зацикливаться, повторять, разветвляться. Сошедший с ума агент может устроить DoS вашему бэкенду.
Rate-лимитинг на вызывающего обязателен:
const limiter = new RateLimiter({
windowMs: 60_000,
max: 100 // 100 calls/minute per caller
});
server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
if (await limiter.exceeded(context.caller.id)) {
return errorResponse("rate_limit", "Too many requests");
}
// ...
});Помимо глобальных лимитов имеют значение лимиты на инструмент — некоторые инструменты дорогие и должны быть ограничены жёстко.
Для существенных операций (создание записей, отправка сообщений) используйте более строгие лимиты или требуйте явных confirmation-флоу.
Паттерн 10: ресурсы
В MCP есть «ресурсы» — read-only источники данных, которые LLM может листать и на которые может ссылаться. Отличаются от инструментов (которые вызываются активно).
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "doc://my-server/handbook",
name: "Employee Handbook",
mimeType: "text/markdown",
description: "Company employee handbook"
},
// ...
]
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const content = await loadResource(request.params.uri);
return { contents: [{ uri: request.params.uri, mimeType: "text/markdown", text: content }] };
});Ресурсы полезны для:
- Справочных документов, которые LLM может захотеть пролистать.
- Конфигурации или контекстных данных.
- Lookup-таблиц или схем, которые могут понадобиться LLM.
Ресурсы читают; инструменты действуют. Используйте подходящую концепцию для каждой задачи.
Паттерн 11: observability
Те же паттерны, что и в остальной production AI. Для вашего MCP-сервера инструментируйте:
- Каждый вызов инструмента: timestamp, вызывающий, инструмент, параметры, результат, latency, статус.
- Метрики по инструменту: объём вызовов, p50/p95 latency, error rate.
- Метрики по вызывающему: кто вызывает, как часто.
- Контекст трассировки: пробрасывайте trace-ID от вызывающего до бэкенд-вызовов.
Структурированные логи:
logger.info("tool_call", {
tool: request.params.name,
caller_id: context.caller.id,
trace_id: context.trace_id,
params: redactPII(request.params.arguments),
duration_ms: duration,
status: "success"
});Лейте в свою observability-платформу.
Паттерн 12: версионирование
Ваш MCP-сервер будет эволюционировать. Инструменты будут меняться. Будут добавляться новые. Старые — устаревать.
Версия сервера. Конструктор Server принимает версию. Поднимайте её при изменениях. Клиенты могут отследить.
Версии инструментов. Когда сигнатура инструмента меняется несовместимо, версионируйте: search_customers_v2. Держите старую версию доступной в течение периода депрекации.
Эволюция схемы. Добавлять опциональные поля безопасно. Удалять поля или менять типы — ломающее изменение.
Депрекация. Когда депрецируете инструмент, отметьте это в описании: «DEPRECATED: use search_customers_v2 instead.»
Для production-серверов, используемых несколькими клиентами, версионирование обязательно. Внутренние серверы могут быть гибче.
Паттерн 13: тестирование
Как тестировать MCP-сервер?
Юнит-тесты. Логика каждого инструмента с замоканными зависимостями. Стандартное TypeScript-тестирование.
Тесты схем. Схемы валидируются как ожидается. Краевые случаи (отсутствующие поля, неверные типы) обрабатываются корректно.
Интеграционные тесты. Поднять сервер, посылать настоящие MCP-запросы, проверять ответы. @modelcontextprotocol/sdk включает тестовые утилиты.
End-to-end с настоящей LLM. Самое сложное, но самое ценное. Дайте LLM использовать ваш MCP-сервер для реалистичных задач. Проверьте, что модель использует инструменты корректно. Найдите проблемы с описаниями.
Сетап end-to-end теста (псевдокод; точная обвязка клиента зависит от того, какой LLM-клиент вы используете — TypeScript SDK от Anthropic, OpenAI или фреймворк с поддержкой MCP):
// Start your MCP server as a child process or in-memory transport.
const server = await startTestServer();
// Drive an LLM with the MCP tools attached. The exact API depends on the client.
const result = await runAgent({
mcpServer: server,
systemPrompt: "You are a customer service agent...",
userMessage: "Find the customer Alice and check her open tickets",
});
// Inspect the tool calls captured by the server during the run.
expect(server.callLog.map((c) => c.name)).toEqual([
"search_customers",
"list_tickets",
]);End-to-end тесты ловят проблемы с описаниями инструментов, которые юнит-тестам не видны.
Паттерн 14: деплой
Где живёт ваш MCP-сервер?
Stdio (локально). Сервер запускается как процесс; клиент его вызывает. Лучше всего для десктоп-приложений (Claude Desktop, Cursor) и локальных инструментов.
HTTP/SSE (удалённо). Сервер — сетевой сервис. Лучше всего для хостируемых сервисов, общей инфраструктуры, мульти-клиентского доступа.
Для production-серверов:
- HTTP/SSE — обычно правильный выбор.
- Деплойте как любой веб-сервис: контейнеры, балансировка, авто-скейлинг.
- TLS обязателен.
- Health-чеки для деплой-платформы.
- Graceful shutdown для запросов в обработке.
Паттерн 15: вопросы безопасности
MCP-серверы выставляют возможности LLM. LLM можно манипулировать. Последствия для безопасности:
Prompt injection через входы инструментов. Запрос пользователя может содержать текст, который пытается заставить LLM использовать инструменты во вред. Защита:
- Чёткие описания инструментов с указанием ожидаемого использования.
- Авторизация на стороне сервера (независимо от параметров, которые выбирает LLM).
- Подтверждения для существенных действий.
Эксфильтрация данных. Инструменты, возвращающие данные, могут быть использованы во вред — LLM можно обмануть и заставить вернуть чувствительные данные. Защита:
- Проверки авторизации.
- Логирование того, к каким данным кто обращается.
- Детекция аномальных паттернов доступа.
Исчерпание ресурсов. Инструменты, потребляющие ресурсы бэкенда, могут быть использованы во вред. Защита:
- Rate limiting.
- Лимиты ресурсов на вызов инструмента.
- Circuit breaker-ы при деградации бэкенда.
Инъекция через выходы инструментов. Выход инструмента может содержать текст, который, будучи прочитан LLM, манипулирует ею. Защита:
- Санитизация выходов там, где возможно.
- Осторожность с инструментами, возвращающими пользовательский контент.
Это реальные поверхности атаки. Относитесь к MCP-серверам как к любому production API: защита в несколько слоёв.
Полный пример: маленький, но настоящий MCP-сервер
Собирая всё вместе — сервер, выставляющий небольшую CRM:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { db, cache, logger, authenticate } from "./infra.js";
const server = new McpServer({
name: "crm-server",
version: "1.0.0",
});
// === Tool: search_customers ===
server.registerTool(
"search_customers",
{
description: "Search customers by name, email, or company.",
inputSchema: {
query: z.string().describe("Name, email, or company"),
limit: z.number().int().min(1).max(50).default(10),
},
},
async ({ query, limit }, extra) => {
const auth = await authenticate(extra);
const cacheKey = `search:${auth.tenant_id}:${query}:${limit}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const customers = await db.searchCustomers({
tenant_id: auth.tenant_id,
query,
limit,
});
const result = {
content: [{
type: "text" as const,
text: JSON.stringify({
customers,
total_matches: customers.length,
truncated: customers.length === limit,
message:
customers.length === limit
? `Showing first ${limit}; there may be more matches.`
: `Found ${customers.length} customer(s).`,
}),
}],
};
await cache.set(cacheKey, result, { ttl: 60 });
logger.info("search_customers", { tenant: auth.tenant_id, query, results: customers.length });
return result;
}
);
// === Tool: get_customer ===
server.registerTool(
"get_customer",
{
description: "Fetch a single customer by id.",
inputSchema: { customer_id: z.string() },
},
async ({ customer_id }, extra) => {
const auth = await authenticate(extra);
const customer = await db.getCustomer(auth.tenant_id, customer_id);
if (!customer) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Customer ${customer_id} not found. Use search_customers to find by name or email.`,
}],
};
}
return { content: [{ type: "text" as const, text: JSON.stringify({ customer }) }] };
}
);
// === Tool: update_customer_email (with idempotency) ===
server.registerTool(
"update_customer_email",
{
description: "Update a customer's email; pass the same idempotency_key on retry.",
inputSchema: {
customer_id: z.string(),
new_email: z.string().email(),
idempotency_key: z
.string()
.describe("UUID for this update; pass the same value on retry to prevent duplicates"),
},
},
async (params, extra) => {
const auth = await authenticate(extra);
// ... idempotency check, validation, update
return { content: [{ type: "text" as const, text: "ok" }] };
}
);
// ... more tools ...
// Wire up a remote transport (Streamable HTTP) on a chosen port via your HTTP server of choice.
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);Это стартовая структура. Добавьте observability, rate limiting, больше инструментов, более аккуратные схемы — но скелет здесь.
Главный вывод
MCP — это маленький протокол; построение production-grade сервера — настоящая инженерная работа. Награда: ваш сервис становится пригодным для использования любым LLM-агентом, через стандартизованную интеграцию без привязки к конкретной модели.
Паттерны, которые имеют значение: сфокусированный дизайн инструментов, prompt-aware схемы, структурированная семантика ошибок, надёжная аутентификация, идемпотентность, observability, безопасность. Пропустить любой из них — получить MCP-сервер, который падает в продакшене.
Закладывайте их сразу. Тестируйте против настоящих LLM. Итерируйте описания инструментов. На выходе — сервис, которым LLM может пользоваться так же свободно, как и человек, и который масштабируется вместе с быстро растущей вселенной AI-агентов.