Туториал

Как выкатить удалённый 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.