
Меня зовут Илья Гуляев, я работаю в команде Рег.облака над облачными решениями. А в свободное время ковыряю MCP — Model Context Protocol, про него и будет текст. Про сам протокол на Хабре уже написано много, а вот про то, что начинается, когда MCP-серверов в инфраструктуре становится десяток, пока почти ничего.
Спойлер: там начинается самое сложное и интересное. Каждый сервер приносит свои инструменты, описание каждого инструмента улетает в системный промпт LLM, и контекстное окно тает на глазах. На стенде с несколькими серверами и почти шестью десятками инструментов я насчитал около 30 тысяч токенов только на описания — без полезной нагрузки, без пользовательских данных, без истории диалога. И это до того, как я вспомнил про сессии, уведомления и согласование версий протокола.
В этой статье расскажу, как я это разруливаю: ставлю GraphQL-шлюз поверх MCP, прячу за ним десяток серверов и отдаю клиенту один виртуальный MCP вместо десятка настоящих. Будет немного теории, немного архитектуры и пара компромиссов, которые я выбрал сознательно.
Проект открытый — исходники лежат в репозитории: https://github.com/hewimetall/vmcp. Можно поднять локально и потыкать.
Навигация по тексту:
Откуда вообще взялся MCP
MCP появился, чтобы закрыть простой разрыв: ИИ-модели хорошо рассуждают, но сами по себе не имеют прямого доступа к базам данных, файлам или внешним программам.
Сначала подход ReAct научил модель чередовать рассуждение и действие. Дальше пришел function calling в OpenAI. Через него LLM вызывала внешние функции прямо из своего рантайма — меняла состояние, ходила в базу, дергала интеграции. Все это жило монолитом внутри одной системы.
MCP взял ту же идею и вытащил ее в веб. Грубо говоря, монолит инструментов превратился в набор микросервисов, с которыми клиент общается по сети. Идея зашла: SDK для разных языков скачивают больше 97 миллионов раз в месяц (Python и TypeScript SDK, по данным Anthropic, декабрь 2025), и темп только растет. Поэтому проблема масштабирования — не теоретическая.
Сам протокол двунаправленный и работает поверх двух актуальных транспортов: stdio (локальный subprocess) и Streamable HTTP (POST + SSE). Старый отдельный HTTP+SSE-транспорт — legacy, его заменил Streamable HTTP. MCP поддерживает stateful-сессии, но допускает и stateless — состояние опционально для Streamable HTTP. В stdio сессия живет, пока жив процесс.
Архитектура и lifecycle
Модель классическая: host — client — server.

-
host — там, где живет LLM;
-
client — то, что находится между LLM и MCP-сервером и оперирует вызовами;
-
server — отдельный сервис, который предоставляет инструменты.
У соединения три основные фазы плюс отдельная фаза авторизации:
-
Initialization — клиент и сервер договариваются о возможностях и версии протокола.
-
Operation — собственно вызовы инструментов и обмен событиями.
-
Shutdown — закрытие сессии.
С авторизацией интереснее. Она построена на стеке существующих стандартов и работает в четыре шага. Сначала идет фаза Discovery: клиент выясняет, какой тип авторизации поддерживает сервер. Дальше клиент регистрируется — динамически или заранее. На третьем шаге он получает токен доступа. И только после этого начинается обычная работа с HTTP API.
На стороне сервера в протоколе живут три сущности: tools, resources и prompts. На стороне клиента — еще три: sampling (запрос LLM-генерации у клиента), elicitation (запрос ввода у пользователя) и roots (границы файловой системы или workspace). В этой статье речь в основном про tools — их и больше всего, и именно они в первую очередь раздувают промпт.
Tools под микроскопом
Tool с точки зрения протокола — структура из пяти полей:
-
name — идентификатор;
-
description — текст для LLM: когда вызывать инструмент;
-
inputSchema — JSON-схема аргументов;
-
outputSchema — схема результата;
-
annotations — необязательные hints для клиента.
Запомните эту деталь: в модель уходят name, description и inputSchema — по схеме она строит аргументы вызова. Без схемы аргументов модель физически не сможет сформировать вызов. Для клиента и служебных нужд остаются outputSchema (валидация результата) и annotations — необязательные hints для клиента, например readOnlyHint: true. Именно поэтому «30 тысяч токенов» и набегают: в промпт идут описания и JSON-схемы аргументов каждого инструмента.
Методы для работы с инструментами тоже простые:
-
tools/list — выгрузка каталога, она же discovery;
-
tools/call — собственно вызов.
Типичный цикл агента выглядит так:

-
Клиент дергает tools/list, получает каталог.
-
Каталог встраивается в системный промпт LLM.
-
Модель выбирает инструмент — это фаза selection.
-
Клиент ловит выбор и формирует tools/call с нужными аргументами.
-
Сервер исполняет запрос и возвращает результат.
-
Результат уходит обратно в модель, та решает, что делать дальше.
И все это крутится по кругу, пока агент не доберется до финального ответа. Параллельно, через отдельный канал, идут нотификации: например, что каталог поменялся и его пора перевыгрузить.
Пример из жизни: вызов weather.get_forecast с аргументом city возвращает контент-блок вроде «+18C, sunny». Если что-то пошло не так, прилетает структура с error.code. Идентификатор запроса в ошибочном ответе может отсутствовать — ошибка не всегда привязана к конкретному ID.
Пока серверов один-два, все работает прекрасно. А потом приходит масштабирование.
Что не так с масштабированием
Каждый инструмент со своим описанием ложится в системный промпт LLM. На моем стенде — несколько MCP-серверов и около шестидесяти инструментов, и одни только описания съедают порядка 30 тысяч токенов. Причем это не экзотика: один только MCP Atlassian (Jira/Confluence) приносит с собой 34 инструмента. Подставьте сюда свой ценник на токены (или квоту подписки, если работаете не через API), и прикиньте, во что обходится один запрос. Напомню: пользователь ещё ничего не спросил.
Оговорюсь: на стороне клиентов эту проблему частично уже разруливают — например, tool search подгружает описания инструментов по запросу, а не сразу все.
Дальше становится веселее. Каждый сервер держит свою сессию, шлет свои нотификации, согласовывает свою версию протокола. У клиента накапливаются десятки соединений, состояний и таймеров. Поднимать еще один MCP-сервер каждый раз, когда нужна новая интеграция, — это прямой путь к промпту в сотни тысяч токенов и к клиенту, который превращается в стейт-машину неприличного размера.
В какой-то момент я пришел к простой мысли: поставить перед парком серверов что-то одно — общий шлюз.
Решение: MCP gateway на основе GraphQL
Шлюз агрегирует разрозненные MCP-серверы и для клиента выглядит как один виртуальный MCP.

Снаружи он раскладывается на два паттерна:
-
Discovery — подгрузка каталога по запросу.
-
Execute — пакетный вызов нескольких инструментов за один раунд.
Движком я выбрал GraphQL — и не из любви к хайпу, а по конкретным причинам.
Агрегация. GraphQL изначально про то, чтобы прятать множество источников за единой схемой. Это ровно то, что мне нужно: за одним эндпоинтом стоит десяток MCP-серверов, а снаружи они выглядят как единая система.
Известный тулинг. GraphQL прожил в проде достаточно лет, чтобы попасть во все большие корпусы обучения. С высокой вероятностью LLM уже видела GraphQL-запросы и понимает их без дополнительного fine-tuning. Это упрощает адаптацию модели к новой среде.
Валидация. Перед тем как улететь в MCP-сервер, запрос проходит проверку типов. Невалидные вызовы отсекаются на шлюзе — модель быстрее учится не повторять ошибку.
Композиция и кэширование. Несколько вызовов в одном запросе — нативная фича GraphQL, изобретать свой батчинг не приходится. Стандартное кэширование работает из коробки: Query идемпотентен, ответы кэшируются, Mutation — нет.
Легкость. LLM не подгружает каталог целиком. Через GraphQL модель выбирает только тот срез схемы, который нужен под конкретную задачу.
Discovery и Execute на GraphQL
Discovery-паттерн. Классический tools/list выгружает каталог полностью, и этот каталог сразу ложится в промпт. В GraphQL устроено иначе: клиент делает запрос к эндпоинту шлюза, спрашивает только нужный кусок схемы и забирает в промпт только его. На выходе — один раунд-трип, экономия в десятки тысяч токенов и возможность дойти до уровня «дай мне только эти три инструмента» вместо «дай все».
Execute-паттерн. Когда модель выбрала инструменты и пора их звать, шлюз пакует несколько вызовов в один GraphQL-запрос. Внутри он распадется на параллельные обращения к MCP-серверам за шлюзом, агрегирует ответы и вернет их одним блоком. На выходе — меньше сетевых раундов, ниже латентность и меньше боли с асинхронным склеиванием результатов.
Чтение и запись едут по-разному. Тут работает деталь протокола, которая всплыла раньше, — readOnlyHint. Читающие инструменты (readOnlyHint = true) шлюз кладёт под Query, пишущие — под Mutation. И дальше сама семантика GraphQL решает режим агрегации: поля одной Query исполнитель имеет право резолвить конкурентно, поэтому чтения разлетаются по разным upstream-серверам параллельно; поля Mutation спецификация требует исполнять строго последовательно, одно за другим. Мне не пришлось вводить флаг «параллельно/последовательно» — режим определяется типом операции, а тип операции — аннотацией инструмента.
Параллелизм тут — это fan-out по разным серверам. Два вызова к одному и тому же upstream выстроятся в очередь, за каждым сервером стоит один stdio-пайп.
Картинка получается такая: клиент пробрасывает один tool — GraphQL-эндпоинт — вместо шестидесяти инструментов. Промпт похудел, а функционально клиент по-прежнему ходит во все интеграции.
Заглянем внутрь: транспорт и инициализация
Чтобы не казалось, что GraphQL — серебряная пуля, разберем пару деталей самого MCP — дальше это пригодится.
Транспорт — JSON-RPC. Запрос состоит из трех полей: id, method, params. id обязателен, может быть строкой или числом, но не null, и не должен повторяться в рамках одной сессии. Ответ либо успешный (result с нужной структурой), либо ошибочный (error с кодом — обязательно целым числом — и сообщением). В ошибочном ответе id иногда отсутствует — например, если запрос был настолько кривой, что разобрать его не удалось.
Нотификации — те же сообщения, но без id. Канал односторонний: сервер шлет, клиент слушает, ответа никто не ждет.
Отдельно стоит фаза инициализации. Клиент и сервер согласуют поддерживаемые версии протокола и набор capabilities. Если версии не совпали, сервер возвращает свою последнюю версию и подсказывает, что поддерживает. Полу-успешного handshake здесь нет: либо стороны договорились (возможно, на более старой версии), либо клиент дисконнектится.
Как это собрано у меня
Скажу сразу: это открытый пет-проект. Все живет в одном процессе — так проще показывать и отлаживать. Внутри: HTTP-эндпоинт /mcp, OAuth (DCR + JWT), GraphQL-резолвер со сгенерированной схемой и пул upstream-соединений.
Каждый upstream — отдельный MCP-сервер: шлюз поднимает его дочерним процессом и держит соединение живым (supervised, с реконнектом). Свою интеграцию обслуживает каждый — Jira, Context7, Serp, Tavily, searchcode.
Нотификации от upstream’ов (tools/list_changed, progress, logging) сходятся во встроенную шину событий и одним потоком уходят клиенту — fan-in по схеме N→1. Для однопроцессного стенда этого достаточно. Durable-шину — например, на Redis Streams — имеет смысл подключать при scale-out и для audit-log, но это уже раздел «что дальше».
А батчинг вообще работает? Короткий эксперимент
Шлюз дает один tool вместо шестидесяти, но модель все равно может дергать query_graphql по кругу: один факт — один вызов. Тогда экономия на discovery съедается лишними раундами на execute. Я прогнал это на стенде: 7 многочастных задач × 100 реплик, 1400 прогонов, mock вместо живых upstream’ов — меряю только решение модели батчить или нет. Метрика: доля single-shot (tool_call_count == 1) и среднее число вызовов.
|
Рука |
Описание |
Single-shot |
Вызовов μ |
|---|---|---|---|
|
A |
«RULE #1 — BATCH EVERYTHING» + антипаттерны |
85 % |
1,23 |
|
C |
голая схема, без подсказки батчить |
4 %* / 39 %** |
3,55** |
* по сырым 700 прогонам;
** после фильтра ошибок/truncation (у C ~90 % уперлись в turn-cap или timeout — типичная патология «без guidance»)
Тем же стендом проверяется, что шлюз и правда агрегирует: на чтениях окна вызовов к разным серверам пересекаются во времени, на записях идут друг за другом. Код — в репозитории.
Отсюда вывод: GraphQL-агрегация на шлюзе — необходимое условие, но не достаточное. Текст description у единственного инструмента управляет тем, соберет ли модель один aliased-документ или уйдет в N+1. Код харнесса — в репозитории, папка bench, подробные результаты прогонов — в bench/RESULTS.md.
Отдельно можно крутить A/B не описания тула, а system-промпта агента (стили Hermes / Cursor / Claude Code) при фиксированном description — тот же харнесс, флаг –system.
Что еще можно надстроить
Когда есть шлюз с GraphQL-схемой, добавлять новое становится дешевле. Часть из этого я успел реализовать, пока писал статью, часть — пока в планах.
Динамический реестр инструментов уже работает. MCP-серверы описаны в registry.json: шлюз сам поднимает их, забирает tools/list и строит схему, а при дрейфе набора инструментов горячо подменяет ее без перезапуска. Подробности — в docs/upstreams.md.
Трансформации схем. Тоже уже в проекте. Через sidecar-конфиг можно править read_only, description и task_support инструмента, не трогая сам upstream: слой трансформаций живет на шлюзе, MCP-серверам про него знать не нужно. На нем же держится разведение по Query и Mutation, про которое шла речь выше. Дока — docs/upstreams.md, про агрегацию — docs/mcp-aggregation-workshop.md.
Базы данных и REST как узлы. GraphQL не делит мир на «MCP» и «не-MCP». Если LLM-агенту нужен доступ к PostgreSQL или к REST-эндпоинту, эти источники подключаются той же схемой, что и инструменты MCP, через OpenAPI и интроспекцию Postgres. Получается универсальный шлюз данных для клиента, а не только для MCP.
Изоляция и Kubernetes. Шлюз и MCP-серверы хорошо укладываются в Kubernetes: изоляция по namespace на каждого тенанта, helm-chart, автоскейлинг воркеров, cert-manager. Парк микросервисов на то и парк, чтобы изолировать его средствами инфраструктуры.
В проекте уже живут и другие механики: lazy discovery через prompts/list и долгие задачи через MCP Tasks (SEP-1686) — подробности в docs/tasks.md, полный гайд — в docs/README.md.
Что бы я унес из этой истории
Парк MCP-серверов масштабируется не так, как обычные микросервисы. Цена нового сервера — десятки тысяч токенов в каждом запросе LLM. Это надо закладывать в архитектуру с самого начала, а не разруливать постфактум.
MCP — это протокол, а не API. На первый взгляд разница косметическая, на практике — нет. Простой HTTP-прокси перед MCP-серверами не сработает: у него нет инструментов для обработки сессионного стейта, нотификаций и согласования версий. Нужен семантический слой, а не транспортный.
GraphQL поверх MCP — это слой, который решает три задачи разом:
-
экономит токены,
-
упрощает клиента,
-
переносит сложность с агента в инфраструктуру.
Один грамотный шлюз перед парком MCP-серверов работает заметно лучше, чем десяток разрозненных клиентов, каждый со своим стейтом и нотификациями. Если экосистема MCP в вашей инфраструктуре только начинается — поставьте шлюз сразу. Потом будет дороже.
Проект открытый, весь код — в репозитории. Чтобы быстро стартовать, есть готовые релизы, там же docker-образ в артефактах, а quickstart с примером локального запуска — в docs. На связи в комментариях. Задавайте вопросы, спорьте, делитесь своим опытом — особенно если вы уже наступали на эти грабли по-своему.
Автор: runity


