К середине 2026 года MCP (Model Context Protocol) — де-факто стандарт для связи LLM с инструментами. Его представил Anthropic; OpenAI, Google и более широкая экосистема его приняли. Cursor, Claude Desktop, ChatGPT, собственные агенты — все говорят на MCP.
Если вы хотите, чтобы LLM-агенты взаимодействовали с вашим сервисом, MCP-сервер — это способ. И как только вы соберёте один-два таких сервера, вы увидите, что сам протокол невелик. Интересная инженерия — во всём, что вокруг него: проектирование схем, обработка ошибок, аутентификация, потоковая передача, производительность, наблюдаемость.
Эта статья — глубокое погружение в построение MCP-серверов промышленного уровня на TypeScript. Мы разберём паттерны, которые выдерживают реальное использование агентами, а не только механику самого протокола.
Что такое MCP, коротко
MCP — это клиент-серверный протокол, в котором:
- Серверы выставляют инструменты (tools), ресурсы (resources) и промпты.
- Клиенты — это обычно LLM-агенты, которые их потребляют.
Протокол использует JSON-RPC 2.0 (официальная спецификация короткая, и её стоит один раз прочитать). Транспорты — stdio для локальных процессов и Streamable HTTP для удалённых серверов; старый транспорт HTTP+SSE объявлен устаревшим в ревизии спецификации от 2025-03-26, так что любой туториал, построенный на нём, считайте историческим. Аутентификация и безопасность — часть протокола; основные реализации поддерживают 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 и create_x) и обрабатывайте по-разному (требовать явного подтверждения, ключей идемпотентности и т. д.).
Агрегируйте, когда это полезно. get_customer_profile, возвращающий клиента + недавние заказы + тикеты поддержки одним вызовом, часто лучше трёх отдельных вызовов. Агент получает контекст за один заход.
Для сервера, выставляющего, скажем, систему клиентской поддержки, разумный набор — это 8–15 инструментов. Больше — обычно перебор.
Паттерн 2: проектирование схем
У каждого инструмента есть входная схема (параметры, которые LLM должна предоставить) и выход (то, что возвращает ваш инструмент). Схемы — это не только валидация; это ещё и проектирование промптов.
С использованием 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: аутентификация и авторизация
Продакшн-серверам 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_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 намекните на это в описании инструмента:
«Для каждого уникального счёта, который вы создаёте, сгенерируйте UUID и передайте его как idempotency_key. Если вам нужно повторить операцию, используйте тот же UUID, чтобы избежать дублирования.»
Паттерн 7: потоковая передача
Для инструментов, которые производят большие выходы или требуют времени, потоковая передача даёт лучший UX. MCP поддерживает уведомления о прогрессе изнутри обработчика инструмента через аргумент 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: кэширование
Многие вызовы инструментов повторно обращаются к одним и тем же данным. Кэширование может резко улучшить производительность и снизить нагрузку на бэкенд.
Локальный кэш. Внутрипроцессный кэш (например, 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: ограничение частоты запросов
LLM-агенты могут быть на удивление агрессивны — зацикливаться, повторять, разветвляться. Плохо ведущий себя агент может устроить DoS вашему бэкенду.
Ограничение частоты на вызывающего обязательно:
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");
}
// ...
});
Помимо глобальных лимитов имеют значение лимиты на инструмент — некоторые инструменты дорогие и должны быть ограничены жёстко.
Для существенных операций (создание записей, отправка сообщений) используйте более строгие лимиты или требуйте явных процедур подтверждения.
Паттерн 10: ресурсы
В MCP есть «ресурсы» — источники данных только для чтения, которые 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 может захотеть пролистать.
- Конфигурации или контекстных данных.
- Справочных таблиц или схем, которые могут понадобиться LLM.
Ресурсы читают; инструменты действуют. Используйте подходящую концепцию для каждой задачи.
Паттерн 11: наблюдаемость
Те же паттерны, что и в остальном продакшене с ИИ. Для вашего MCP-сервера инструментируйте:
- Каждый вызов инструмента: метка времени, вызывающий, инструмент, параметры, результат, задержка, статус.
- Метрики по инструменту: объём вызовов, задержки p50/p95, доля ошибок.
- Метрики по вызывающему: кто вызывает, как часто.
- Контекст трассировки: пробрасывайте идентификаторы трассировки (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"
});
Направляйте в свою платформу наблюдаемости.
Паттерн 12: версионирование
Ваш MCP-сервер будет эволюционировать. Инструменты будут меняться. Будут добавляться новые. Старые — устаревать.
Версия сервера. Конструктор Server принимает версию. Поднимайте её при изменениях. Клиенты могут отследить.
Версии инструментов. Когда сигнатура инструмента меняется несовместимо, версионируйте: search_customers_v2. Держите старую версию доступной в течение периода вывода из эксплуатации.
Эволюция схемы. Добавлять опциональные поля безопасно. Удалять поля или менять типы — ломающее изменение.
Прекращение поддержки. Когда выводите инструмент из эксплуатации, отметьте это в описании: «DEPRECATED: use search_customers_v2 instead.»
Для продакшн-серверов, используемых несколькими клиентами, версионирование обязательно. Внутренние серверы могут быть гибче.
Паттерн 13: тестирование
Как тестировать MCP-сервер?
Юнит-тесты. Логика каждого инструмента с подменёнными зависимостями (моками). Стандартное TypeScript-тестирование.
Тесты схем. Схемы валидируются как ожидается. Граничные случаи (отсутствующие поля, неверные типы) обрабатываются корректно.
Интеграционные тесты. Поднять сервер, посылать настоящие MCP-запросы, проверять ответы. @modelcontextprotocol/sdk включает тестовые утилиты.
Сквозной тест с настоящей LLM. Самое сложное, но самое ценное. Дайте LLM использовать ваш MCP-сервер для реалистичных задач. Проверьте, что модель использует инструменты корректно. Найдите проблемы с описаниями.
Настройка сквозного теста (псевдокод; точная обвязка клиента зависит от того, какой 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",
]);
Сквозные тесты ловят проблемы с описаниями инструментов, которые юнит-тестам не видны.
Паттерн 14: развёртывание
Где живёт ваш MCP-сервер?
Stdio (локально). Сервер запускается как процесс; клиент его вызывает. Лучше всего для десктоп-приложений (Claude Desktop, Cursor) и локальных инструментов.
HTTP/SSE (удалённо). Сервер — сетевой сервис. Лучше всего для размещённых сервисов, общей инфраструктуры, многоклиентского доступа.
Для продакшн-серверов:
- Streamable HTTP — обычно правильный выбор.
- Развёртывайте как любой веб-сервис: контейнеры, балансировка, автомасштабирование.
- TLS обязателен.
- Проверки работоспособности для платформы развёртывания.
- Плавное завершение для запросов в обработке.
Паттерн 15: вопросы безопасности
MCP-серверы выставляют возможности LLM. LLM можно манипулировать. Последствия для безопасности:
Внедрение промпта (prompt injection) через входы инструментов. Запрос пользователя может содержать текст, который пытается заставить LLM использовать инструменты во вред. Защита:
- Чёткие описания инструментов с указанием ожидаемого использования.
- Авторизация на стороне сервера (независимо от параметров, которые выбирает LLM).
- Подтверждения для существенных действий.
Утечка данных (эксфильтрация). Инструменты, возвращающие данные, могут быть использованы во вред — LLM можно обмануть и заставить вернуть чувствительные данные. Защита:
- Проверки авторизации.
- Логирование того, к каким данным кто обращается.
- Обнаружение аномальных паттернов доступа.
Исчерпание ресурсов. Инструменты, потребляющие ресурсы бэкенда, могут быть использованы во вред. Защита:
- Ограничение частоты запросов.
- Лимиты ресурсов на вызов инструмента.
- Автоматические предохранители (circuit breakers) при деградации бэкенда.
Инъекция через выходы инструментов. Выход инструмента может содержать текст, который, будучи прочитан LLM, манипулирует ею. Защита:
- Очистка выходов там, где возможно.
- Осторожность с инструментами, возвращающими пользовательский контент.
Это реальные поверхности атаки. Относитесь к MCP-серверам как к любому продакшн-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);
Это стартовая структура. Добавьте наблюдаемость, ограничение частоты запросов, больше инструментов, более аккуратные схемы — но скелет здесь.
Что отличает продакшн-сервер от демо
MCP — это маленький протокол; построение сервера промышленного уровня — настоящая инженерная работа. Награда: ваш сервис становится пригодным для использования любым LLM-агентом, через стандартизованную интеграцию без привязки к конкретной модели.
Паттерны, которые имеют значение: сфокусированный дизайн инструментов, схемы, ориентированные на промпт, структурированная семантика ошибок, надёжная аутентификация, идемпотентность, наблюдаемость, безопасность. Пропустить любой из них — получить MCP-сервер, который падает в продакшене.
Закладывайте их сразу. Тестируйте против настоящих LLM. Итерируйте описания инструментов. На выходе — сервис, которым LLM может пользоваться так же свободно, как и человек, и который масштабируется вместе с быстро растущей вселенной ИИ-агентов.



