Туториал
Как выкатить удалённый MCP-сервер в прод: безсостояние MCP 2026 + HTTP-балансировщик + JSON-RPC
Безсостояние протокола стоит денег, только если вы реально используете обычный HTTP-балансировщик. Прод удалённого MCP — это масштаб реплик, прокси, которые не копят SSE, и drain, который не режет длинные потоки, а не ещё одна сессия.
После 2026-07-28 удалённый MCP больше не прибивает клиента к одному процессу через initialize плюс Mcp-Session-Id. Каждый JSON-RPC-запрос несёт версию протокола и возможности клиента в _meta; Streamable HTTP зеркалит нужные поля в HTTP-заголовки, чтобы балансировщик и шлюз маршрутизировали, ограничивали и писали метрики без разбора тела. Труд в проде — не написать tools/call. Это как растить реплики, какую политику баланса выбрать, как не дать nginx буферить прогресс SSE, как дренировать subscriptions/listen при выкладке и куда класть прикладное состояние. Зачем протокол безсостояния — в Stateless MCP; кто может звать — в OAuth 2.1. Здесь только «как повесить за HTTP-балансировщик». Транспорт: Streamable HTTP. Конверт: JSON-RPC 2.0. Не надевайте эту публичную топологию на локальный дочерний процесс stdio.
Ловушка прода: липкая сессия сводит горизонтальный масштаб к одной точке
Многие слышат «безсостояние» и выкатывают один контейнер. Выигрыш появляется только с обычным HTTP-балансировщиком — round-robin, least-conn, вес по CPU — а не с липкой сессией. Старые ревизии держали сессию на соединение: после рукопожатия следующие tools/call должны были попасть в процесс с Mcp-Session-Id. Больше реплик означало cookie-липкость или IP-хеш на ALB или nginx; одна мёртвая коробка убивала всю сессию агента. 2026-07-28 убрал сессии протокола. Любая реплика должна сама закончить любой POST.
Cursor, Claude Desktop, удалённый MCP OpenAI и самодельные хосты агентов не вежливо сериализуют на одну точку. В ту же секунду могут прийти tools/list, tools/call и долгий subscriptions/listen. Эти три прыжка не обязаны сесть на один инстанс. Липкость по IP клиента сводит горизонтальный масштаб к одной точке и замораживает лимиты и канарейку на одной коробке. Вход в протокол: что такое MCP. Цикл агента: что такое ИИ-агент.
Против других слоёв: безсостояние убрало сессию протокола, не бизнес-данные. Черновики счетов, корзины, незаконченные многораундовые вызовы инструментов по-прежнему идут в Redis или базу и возвращаются через draftId в arguments. Карта в памяти процесса — не прод-состояние. Auth тоже не этот слой — Bearer для публичного удалённого MCP живёт в HTTP-заголовках; см. статью про OAuth. Здесь три слоя уже разделены; речь только о топологии и операционном контракте.
| Подход | Что видит балансировщик | Итог в проде |
|---|---|---|
| Липкость cookie / IP + сессия в процессе | Должен приклеить того же клиента к тому же upstream | Трудно расти, выкладка рвёт, одна точка отказа |
| Безсостоятельные реплики + обычный HTTP LB | Любая реплика может взять любой JSON-RPC POST | Горизонтальный масштаб, канарейка, лимиты по инструменту |
| Холодный старт serverless + один POST | Нет долгого процесса, который «помнит» рукопожатие | Нормально для короткого RPC; длинный SSE — свои таймауты |
Целевая схема: клиент → HTTP LB → N безсостоятельных реплик
Держите топологию тонкой: публичный DNS → завершение TLS (ALB, NLB плюс sidecar, nginx, Caddy, облачный LB) → набор реплик MCP с тем же образом и той же конфигурацией. Не вешайте спереди «хранилище сессий MCP», чтобы залатать протокол. Проверки здоровья — отдельный GET /healthz: процесс жив, зависимости доступны. Не POSTьте пустое тело на /mcp и не используйте живой tools/list как зонд — это бьёт в настоящий реестр и может дать 401, ложно сняв реплику.
Каждая реплика должна закончить один прыжок сама: проверить Origin (DNS rebinding → 403), прочитать MCP-Protocol-Version / Mcp-Method / Mcp-Name, при защите ресурса проверить Bearer, разобрать конверт JSON-RPC, выполнить инструмент, вернуть один JSON-объект или SSE в области этого запроса. Спецификация хочет одну MCP-точку, которая принимает POST, например https://mcp.example.com/mcp. GET-потоки и сессии протокола исчезли в 2026-07-28. Не открывайте снова GET /sse «для старых проб».
Реплики делят прикладные зависимости: база, объектное хранилище, сторонние API, опциональный Redis. Они не делят «какие MCP-соединения открыты». Cloud Run, Cloud Functions, Knative с масштабом на запрос подходят этой модели. Что ещё крутите — idle-таймаут длинного SSE, не липкость сессии. Процесс — непривилегированный пользователь за прокси; не слушайте MCP на 0.0.0.0:80 в открытой сети.
Как JSON-RPC 2.0 пересекает балансировщик
MCP кодирует сообщения как JSON-RPC 2.0, UTF-8 обязателен. На Streamable HTTP каждый запрос или уведомление клиента — новый HTTP POST; серверы не начинают JSON-RPC-запросы. Балансировщику не нужен деловой смысл method — он гонит байты. Транспорт 2026 зеркалит method в Mcp-Method, а имена инструментов / ресурсов / промптов в Mcp-Name, чтобы посредники лимитировали по инструменту, резали по методу и канарили по версии без разбора тела.
Тело остаётся истиной. MCP-Protocol-Version в заголовке должен побайтно совпасть с params._meta.io.modelcontextprotocol/protocolVersion, иначе сервер ДОЛЖЕН вернуть 400 с HeaderMismatch. Клиенты также шлют Accept: application/json, text/event-stream. Принятый notification POST даёт 202 Accepted без тела; запрос даёт один JSON-объект или SSE. id JSON-RPC парует запрос и ответ этого прыжка. Это не ключ сессии и им нельзя «доставать контекст» между репликами.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32600,
"message": "HeaderMismatch: MCP-Protocol-Version does not match params._meta"
}
}
Ниже прод-форма tools/call: auth в HTTP-заголовке, конверт в теле, ключи маршрута, которые видит шлюз, тоже в заголовке. Засовывать Access Token в params или _meta — не удалённый MCP 2026. Форма аргументов по-прежнему нужна проверка Schema: MCP и JSON Schema.
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: searchInvoices
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "searchInvoices",
"arguments": {
"startDate": "2026-01-01",
"endDate": "2026-01-31",
"status": "paid"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "jsonvue-demo",
"version": "1.0.0"
}
}
}
}
Streamable HTTP: буфер, таймауты и SSE
Короткие вызовы инструментов должны возвращать Content-Type: application/json. Долгая работа может вернуть text/event-stream: сначала связанные с запросом notifications/progress, затем финальный JSON-RPC-ответ, который закрывает поток. Большинство прод-инцидентов сидят в обратном прокси, не в SDK MCP. По умолчанию nginx proxy_buffering копит события прогресса и смывает их одним куском; агент выглядит застрявшим. Спецификация велит серверам слать X-Accel-Buffering: no для посредников. См. nginx proxy_buffering.
На Streamable HTTP сигнал отмены — это клиент закрывает этот SSE, а не последующий POST notifications/cancelled (это привязка stdio). LB должен протащить обрыв бэкенда к клиенту и обрыв клиента к реплике, чтобы воркер остановился. Не ставьте посередине шлюз, который «повторяет POST»: JSON-RPC-запросы по умолчанию не идемпотентны, и tools/call мог уже записать базу.
subscriptions/listen — другой длинный поток: ответ остаётся открытым и несёт изменения вроде tools/list_changed, не прогресс одного вызова. Спецификация советует периодические строки-комментарии SSE (строка с двоеточия) как keep-alive, чтобы простаивающие посредники не вешали трубку. Возобновляемый SSE через Last-Event-ID не поддерживается. Idle/read-таймауты LB должны быть длиннее интервала keep-alive; дефолтные 60 с у Cloudflare, ALB и nginx часто малы. Фрагмент ниже — минимальный набросок обратного прокси, не база безопасности. TLS, лимиты и WAF настраиваются отдельно.
upstream mcp_replicas {
least_conn;
server 10.0.1.11:8080;
server 10.0.1.12:8080;
server 10.0.1.13:8080;
}
server {
listen 443 ssl;
server_name mcp.example.com;
location /healthz {
proxy_pass http://mcp_replicas;
proxy_connect_timeout 2s;
proxy_read_timeout 3s;
}
location /mcp {
proxy_pass http://mcp_replicas;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Rolling-выкладка, drain и subscriptions/listen
Безсостоятельные реплики упрощают выкладку; SSE всё ещё летящий HTTP-запрос. Порядок: снять инстанс с целевой группы → остановить новый трафик /mcp → дождаться конца открытых JSON-ответов и SSE или объявленного drain-таймаута → затем SIGTERM воркеру. Не SIGKILL процесс, который ещё толкает прогресс. Возвращайте только после зелёных healthcheck.
Новая версия должна по-прежнему принимать старую форму запроса. Нет шага протокола «сначала обновить рукопожатие, потом переключить трафик». Канарейка — процент POST на новый образ. MCP-Protocol-Version может быть ключом маршрута: только клиенты, заявившие 2026-07-28, входят в новый пул; старые остаются в пуле совместимости. При несовпадении версии — 400 плюс UnsupportedProtocolVersionError. Не понижайте молча и не продолжайте гонять инструменты.
subscriptions/listen в окне публикации почти всегда оборвётся. Клиенты должны открыть listen заново и не считать, что события не потеряны. Серверы не должны копить недоставленные list_changed в памяти процесса. Нужна надёжная доставка — пишите внешнюю очередь; listen только рот подписки. MRTR (многораундовый ввод) тоже независимые POST: промежуточный результат в общее хранилище, чтобы следующий прыжок мог сесть на другую реплику.
Лимиты шлюза, auth, телеметрия и JSONVue
Если шлюз видит Mcp-Method и Mcp-Name, ставьте QPS по инструменту, не только по IP источника. Дорогие инструменты (запись, платежи, длинный SQL) — своя квота; tools/list может быть свободнее. В открытой сети проверяйте Origin и будьте Resource Server OAuth 2.1 — 401, Protected Resource Metadata, Bearer на каждом прыжке; см. MCP OAuth 2.1. Auth на транспорте, лимиты на шлюзе, Schema в бизнес-слое. Не месите три слоя в одну прослойку.
| Поле телеметрии | Откуда берётся | Зачем нужно |
|---|---|---|
| MCP-Protocol-Version / Mcp-Method / Mcp-Name | Заголовки запроса (сверка с _meta тела) | Лимиты по инструменту, канарейка, дашборды |
| JSON-RPC id | Конверт этого прыжка | Связать повторы клиента с логами реплики |
| HTTP-статус + JSON-RPC error.code | Транспорт vs слой метода | Отличить 401 / HeaderMismatch / бизнес-ошибку |
В access-логах оставьте хотя бы эти три группы. В логах реплики добавьте, равен ли jsonrpc 2.0 и дошёл ли инструмент до побочного эффекта. Расхождение заголовка и тела, нет Accept, плохой Origin должны умирать на шлюзе или краю реплики, не внутри функции инструмента. Держите по одному образцу сбоя: HeaderMismatch, 401, дырки в arguments, забуференный SSE (клиент увидел только последний кусок).
Лабораторию можно закрыть в браузере:Форматтер JSON, чтобы увидеть, парсится ли конверт;Проверка JSON Schema для params.arguments;JSON Diff, чтобы сравнить два ответа с ошибкой;декодер JWT для Bearer aud на публичном деплое. Данные остаются на этой машине.
Дальше: что такое MCP, Stateless MCP, OAuth 2.1, MCP и JSON Schema, A2A vs MCP.
FAQ
Нужны ли в проде ещё sticky-сессии?
На слое протокола 2026-07-28 — нет. Sticky только заставляет думать, что сессия ещё есть. Если приложению нужно липнуть к региону или шарду данных, маршрутизируйте по Mcp-Param-* или ключу арендатора в arguments — прикладной маршрут, не cookie-липкость к процессу MCP.
Можно ли проверять балансировщик через GET /mcp?
Нет. Современная MCP-точка принимает только POST; GET-потоки убраны. Случайные GET на /mcp дают 405 или путают старую совместимость. Зонд — отдельный /healthz: процесс и зависимости, без запуска инструментов.
Если SSE оборвался, продолжать ли через Last-Event-ID?
Спецификация не поддерживает возобновляемый SSE. Клиенты заново открывают соответствующий запрос (listen → новый subscriptions/listen; долгий инструмент → повтор только если дело идемпотентно). Строки-комментарии keep-alive плюс прокси без буфера лучше соответствуют контракту, чем свой кэш event id.
Локальный stdio MCP тоже сажать за балансировщик?
Нет. stdio — дочерний процесс, который поднял клиент; байты едут по stdin/stdout без HTTP-прыжка. Баланс, проверки Origin, Bearer и X-Accel-Buffering относятся к Streamable HTTP / удалённому. Одни и те же инструменты могут отдать оба транспорта; прод-топология оборачивает только HTTP-лицо.
Итог и дальше
Прод-архитектура удалённого MCP — одно предложение: безсостоятельный JSON-RPC-запрос пересекает обычный HTTP-балансировщик и садится на любую одинаковую реплику; длинные потоки открываются в области запроса, не соединения. Заголовки — для шлюза, тело — истина, прикладное состояние — во внешнем хранилище.
Выкатывайте в таком порядке: любая реплика сама заканчивает один tools/call, затем выключите буфер, поднимите таймауты и добавьте drain, затем лимиты по инструменту и канарейка. Держите успешный конверт, HeaderMismatch и 401 в JSONVue на этой машине. Семантика протокола: Stateless MCP. Кто может звать: OAuth 2.1.