В прошлой статье про корпоративного AI-ассистента был абзац о том, что мцп серверов у меня много, поэтому пришлось написать прослойку, которая раздаёт тулзы дозированно. Расскажу, как я к этому пришел, потому что на самом деле не все так очевидно.
Как это начиналось
Прослойку я написал, когда мцп серверов было штук пять, и написал скорее на упреждение. Уже тогда было понятно, что будет много кубовых кластеров, несколько гитлабов плюс всякие мониторинги прометеусы и тд. То есть однотипных серверов будет много, а тулзы у них называются одинаково, и хотелось не перегружать контекст однотипными описаниями ну и чтобы модель в них не путалась.
Один вид вместо пяти инстансов
Каждый мцп сервер это отдельный инстанс, который предоставляет свою пачку тулзов и описаний. k8s-cluster-1, k8s-cluster-2 и тд, это разные инстансы одного и того же мцп, но каждый отдаёт свой набор тулзов с одинаковыми именами. Модели незачем видеть kubectl_get пять раз. Ей надо знать, что такой тул есть и что кластеров пять, а какой именно кластер - это уже параметр вызова, имя сервера.
Поэтому у каждого сервера в конфиге появилось поле kind:
{ "servers": [ {"name": "k8s-cluster-1", "kind": "kubernetes", "url": "..."}, {"name": "k8s-cluster-2", "kind": "kubernetes", "url": "..."}, {"name": "gitlab-a", "kind": "gitlab", "url": "..."}, {"name": "gitlab-b", "kind": "gitlab", "url": "..."} ] }
А мультиплексор сворачивает инстансы в вид и складывает имена в сет:
// упрощённо for name, srv := range mx.servers { kind := srv.config.Kind serversByKind[kind] = append(serversByKind[kind], name) for _, t := range srv.tools { toolSets[kind][t.Name] = struct{}{} } }
KindGroups() отдаёт наружу три вещи: виды, серверы каждого вида и дедуплицированные имена тулзов. В промт оркестратора из этого едут только первые две, по строке на вид:
kubernetes: k8s-cluster-1, k8s-cluster-2, k8s-cluster-3, k8s-cluster-4, k8s-cluster-5 gitlab: gitlab-a, gitlab-b, gitlab-a-write, gitlab-b-write prometheus: prometheus-a, prometheus-b, prometheus-c opensearch: opensearch-a, opensearch-b jira: jira wiki: wiki ...
Когда появилсь субагенты, имена мцпшных тулзов и описания стали оркестратору не нужны, он инструменты сам не вызывает, он выбирает сервер и спавнит субагента. Весь его справочник по 23 серверам превратился в тринадцать строк и 180 токенов.
А субагенту достаётся полный набор: все тулзы его серверов, с полными описаниями и схемами, потому что какие именно понадобятся, заранее не знает никто, и понять это может только сам агент, которого ещё не запустили. Зато серверов у него обычно два-три, и полный набор в этих границах стоит недорого: разбор инцидента это три сервера, 40 инструментов и 9 тысяч токенов при окне в 131 тысячу. А точность попадания в аргументы напрямую зависит от полноты описаний, так что резать тут - экономия на спичках при цене в лишний цикл вызовов.
Дальше я подключал серверы по одному, по мере надобности: гитлаб, кубы, Jira, прометей, трейсы. Каждое подключение по отдельности безобидное, ну ещё один сервер, ну ещё десяток тулзов. Сейчас их 23, и они отдают 733 пары (сервер, инструмент) - а уникальных имён среди этих пар всего 284, остальные - дубли.
Потом начались аргументы
С выбором тула стало нормально, и почти сразу вылезло следующее. Модель зовёт правильный инструмент, но с мусором в аргументах. То пустая строка, то undefined, то <none> прямо как есть, то массив там, где сервер ждёт строку.
Первое, что я сделал - написал в промте, чтобы модель так не делала. Но локальные модели это локальные модели, они не особо то следуют инструкциям, поэтому промт то срабатывал, то не срабатывал. Да и модель ведь не знает, что у неё в аргументе заглушка, для неё это такой же текст, как всё остальное. Так что запрещать было бесполезно, и я сделал то же самое кодом. Теперь пустые строки и известный набор заглушек (undefined, null, none, <none>, <unknown>) до сервера не доезжают, а всё остальное проверяется по жсон-схеме тулза - так ловится и массив там, где ждали строку. Отклонение приходит модели текстом, с перечнем того, что именно не так, и следующая попытка обычно уже корректная.
А потом выяснилось, что и с нормальными аргументами всё не так гладко, и вот из этого уже выросли нормализаторы.
Мцп сервера все разные, и поэтому один условно ждёт podName, другой pod, третий просто name. Один принимает массив, другой строку через пробел. Модель субагента про это хоть и знает, но не всегда соблюдает, поэтому нормализация живёт в конфиге на уровне вида:
"kind_settings": { "kubernetes": { "args_transformer": ["camelCase"], "field_map": {"podName": "name", "pod": "name"} } }
Применяется это всё до вызова, модель просто зовёт инструмент и получает результат.
Встроенных нормализаторов на самом деле всего три: camelCase, joinArrays и singularResourceType (этот приводит pods к pod и прочие плюралы, кубовые мцп такое любят). Но можно подкинуть свой - регистрируешь функцию под именем через WithArgsTransformer, и дальше ссылаешься на это имя из конфига наравне со встроенными.
Тут важно, что дело не в количестве серверов, а в их разнородности. Пока у меня их было три и все от одного автора, никакая нормализация не требовалась, у них общие соглашения и всё сходилось само. Разъезжаться начало, когда серверов стало полтора десятка и писали их разные люди. Так что если у вас пачка мцп работает из коробки - скорее всего вам просто повезло с набором )
Хуки вызовов
Через мультиплексор проходит каждый вызов к внешней системе, и постепенно выяснилось, что это удобное место, чтобы навесить хуки вызовов: до вызова, после вызова, преобразование результата и обогащение метаданных. На них сейчас живут права на инструменты, вынос слишком больших ответов в память, обезвреживание инструкций, и метрики по каждому серверу.
А потом я решил посчитать токены
Когда сел писать эту статью, стало любопытно, сколько теперь весит в токенах вся информация про мцп сервера и их инструменты? Думал выйдет дорого, но терпимо.
Вышло 170 256 токенов, в то время как окно у основной модели - 131 072.
То есть сейчас каталог в чистом виде в модель уже вобще не влезает. Даже если больше ничего в контекст не класть - ни системный промт, ни история диалога, ни сам вопрос пользователя.
Померил все варианты: отдать всё, отдать обзор по видам и отдать набор под конкретную подзадачу.
что отдаём |
серверов |
инструментов |
токенов |
|---|---|---|---|
весь каталог |
23 |
733 |
170 256 |
обзор по видам, с именами тулзов |
23 |
284 имени |
3 121 |
справочник видов, как он едет оркестратору |
23 |
- |
180 |
набор субагенту: разбор инцидента |
3 |
40 |
9 132 |
набор субагенту: поиск по коду |
2 |
127 |
26 970 |
Обзор по видам выходит в 55 раз дешевле полного каталога, потому что в нём одни имена, без схем и описаний. А оркестратору и имена тулзов не нужны, поэтому у него в промте 180 токенов - в 950 раз меньше каталога. Дальше выбранный им набор мцп серверов уезжает агенту целиком, со схемами.
Напоследок
Из всего этого всего я сделал два вывода: запрещать модели что то в промте бесполезно, рано или поздно модель забьет на инструкции, нужны детерминированные гейты и проверки кодом. И однотипные мцп надо сворачивать в вид сразу, и делать кластер, контур или инстанс аргументом вызова, а не отдельным мцп, а иначе каталог будет расти линейно от числа стендов и ты упираешься в лимит контекстного окна на ровном месте.
Итак, для стандартизации вызовов к разным мцп получилась опен сорс библиотека которая убирает дубли тулзов и их описаний из контекста модели, а заодно предоставляет хуки для вызовов тулзов и нормализацию аргументов. Сама библиотека тут. Транспорты stdio, HTTP и SSE, адаптер под eino. Буду рад issue и вопросам.
И вопрос к тем, кто такое строит. Сколько у вас мцп серверов и на каком количестве вы упёрлись в размер каталога? Мне кажется, я упёрся довольно рано, и интересно, у всех ли это происходит на втором десятке.
Комментарии (3)

Zantiago
30.07.2026 12:52Интересная архитектура. По сути, в какой-то момент MCP начинает требовать собственного control plane: каталог, роутинг, нормализация, права, хуки, метрики.
мне кажется, самый неприятный вопрос начинается после дедупликации так как одинаковое имя инструмента ещё не гарантирует одинаковую семантику и даже совместимость схем между разными версиями серверов: пока различия сводятся к podName vs pod, нормализатор выглядит отлично, но если один сервер немного иначе трактует фильтр, namespace или destructive-операцию, прослойка может уже не исправить ошибку, а незаметно её замаскировать.
есть ли у вас проверка совместимости инструментов внутри одного kind? например fingerprint схем, версионирование capabilities или запрет объединения при расхождении контрактов? думаю на следующем масштабе именно drift между серверами станет большей проблемой, чем размер каталога

fs94eskoe Автор
30.07.2026 12:52у меня нет внутри вида разных версий, если новая версия приходит я весь вид обновляю, а если понадобится чтобы один мцп был другой версии то просто будет новый кайнд типа gitlab-2 или можно будет как то похитрее, с дифами что то придумать, либо в под к мцп сделать сайдкар контейнер с отдельным нормализатором.
ulaeliseeva
Думаю, это правильное решение. Если ИИ давать сразу всё, он только запутается. Лучше показывать ему только то, что нужно в данный момент. Так и работает быстрее, и ошибок меньше.