С того момента, как я подумал: «Если объединить их в один проект, проектов станет на один меньше», процесс решения самой задачи уже начался.
Мы решили добавить в нашу внутреннюю платформу ИИ-агента, чтобы сервисами можно было управлять с помощью естественного языка. Но первым вопросом было не «Что нам следует создать?», а «Сколько компонентов нам следует создать?». Поскольку мы планировали разместить разговорный интерфейс внутри существующих экранов сервиса, нам нужно было определить, смогут ли MCP-сервер и хост агента сосуществовать в одном проекте.
Объединение казалось привлекательным. Это позволило бы сократить количество единиц развёртывания на одну, устранить межпроцессное взаимодействие и отказаться от необходимости переключаться между кодовыми базами. Фактически именно это направление мы первоначально и рассматривали.
В итоге мы решили разделить их. Позже стало ясно, что вопрос заключался не просто в количестве проектов. Такие вопросы, как место размещения управления и сторона, ответственная за определение экрана подтверждения, также решались по этой границе.
В этой статье обобщены причины разделения системы на два проекта и метод проб и ошибок, использованный при проектировании слоя управления по обе стороны этой границы. В ней рассматривается случай, когда управление было размещено в двух местах, а внешний слой перехватывал более удачный ответ внутреннего слоя, а также структура, в которой слой, отрисовывающий экран подтверждения, владел его определением, из-за чего воспроизводились незаметные сбои.
1. Предпосылки выбора технологии — почему их не следует объединять
Я не мог принять решение, потому что мне было неясно, для чего предназначен MCP. Когда я напрямую рассмотрел этот вопрос, ответ стал очевиден. MCP — это протокол для пересечения границ процессов. При использовании в одном процессе MCP превращается в структуру, которая отправляет JSON-RPC самой себе. Иными словами, после интеграции компонентов использовать MCP уже нет смысла.
С этой точки зрения остальные причины следовали естественным образом.
-
Принцип управления не работает. Идея двойного контроля — когда MCP-сервер выполняет повторную финальную проверку на основе политик даже после одобрения со стороны хоста — имеет смысл только тогда, когда эти два слоя действительно разделены.
-
MCP-серверы используются несколькими хостами. К одному и тому же серверу подключаются не только внутренние агенты, но и внешние MCP-клиенты. Как только сервер начинает зависеть от хоста, его повторное использование становится невозможным.
-
Их характеристики задержки и стоимости диаметрально противоположны. Вызовы LLM медленные и дорогие, тогда как поиск инструментов быстрый и недорогой. При совместном развёртывании они начинают мешать друг другу.
В ходе проверки мы также исключили один вариант: использование готового SDK для агентов, чтобы сократить количество проектов. Он не подходил по двум причинам. Наш внутренний стек написан на Java, тогда как SDK поддерживал только Python и TypeScript. Что ещё важнее, он не сокращал количество проектов. Поскольку компоненты, написанные на разных языках, нельзя объединить, разделение фактически стало бы обязательным.
В ходе этого обсуждения прояснилось ещё одно заблуждение. Создание агента самостоятельно не означает, что мы сами создаём процесс вывода. Решения всегда принимает универсальная LLM; мы создаём цикл выполнения, интеграцию с инструментами, политики и аудит. Поэтому мы сформулировали принцип одной строкой: делегировать вывод универсальной LLM, а непосредственное управление выполнением оставить за платформой.
2. Процесс и детали реализации
2.1. Два процесса и распределение обязанностей
Архитектура была окончательно определена как два сервиса. MCP-сервер отвечает за список и карту инструментов, а также за политики, подтверждения и аудит. Хост агента отвечает за обработку диалога, вызовы LLM и цикл выполнения. Обе стороны используют Java 25 / Spring Boot 4.1.
Здесь важен был не сам факт разделения процессов, а решение о том, что должно относиться к каждой стороне после разделения. Если это решение нестабильно, в итоге вы получаете два процесса со спутанными обязанностями. Именно это и произошло практически сразу.
2.2. Концентрация ответственности за управление — ловушка двойного контроля
Создавая инструменты, которые действительно что-то изменяют, например инструмент загрузки, мы добавили карточку подтверждения для подтверждения человеком перед выполнением. Критерии были такими: «Не заставлять пользователей вводить идентификаторы, не задавать уточняющие вопросы и сделать весь процесс проще, чем использование экрана».
Однако в первой реализации вызов инструмента, требовавшего подтверждения, возвращал строку ошибки вместо карточки. Модель получала эту строку, объясняла её на естественном языке и снова просила пользователя ввести внутренний идентификатор. Карточка так и не появлялась.
Причин было две. Во-первых, ожидание подтверждения выбрасывалось как исключение. Во-вторых, агент также содержал логику блокировки подтверждения, не позволяя MCP-серверу даже попытаться создать предложение. Поскольку управление избыточно присутствовало в обоих слоях, внешний слой перехватывал более удачный ответ внутреннего слоя.
Поэтому мы передали ответственность за решение о подтверждении только MCP-серверу, а агент сделали транзитным звеном. Мы также изменили ожидание подтверждения: теперь оно возвращается в обычном режиме как структурированное предложение выполнения, а не как ошибка.
[Возврат ожидания подтверждения как предложения, а не ошибки]
// A pending approval is not an error. Returning it as a structure keeps the
// decision with the person, instead of letting the model narrate a failure.
static String of(String approvalId, MethodSignature signature, Object[] args,
McpTool mcpTool, NaviToolPolicy policy, ObjectNode form) {
ObjectNode proposal = OBJECT_MAPPER.createObjectNode();
proposal.put("proposalType", "approval.required");
proposal.put("approvalId", approvalId);
proposal.put("tool", mcpTool.name());
proposal.put("risk", policy.risk());
// 'arguments' keeps the original values for re-execution after approval.
// 'fields' carries only what a person needs to read on the card.
ObjectNode arguments = proposal.putObject("arguments");
ArrayNode fields = proposal.putArray("fields");
for (int i = 0; i < parameters.length && i < args.length; i++) {
arguments.putPOJO(names[i], args[i]);
if (isHidden(names[i])) continue; // internal ids stay out of sight
fields.addObject().put("label", label(parameters[i], names[i]))
.putPOJO("value", args[i]);
}
return proposal.toString();
}
Разделение аргументов и полей стало ключом к этому проектированию. Исходные аргументы, необходимые для выполнения, сохраняются без изменений в arguments и используются для повторного выполнения после подтверждения, тогда как карточка отображает только значения, которые человек должен подтвердить. Ключи, которые пользователю незачем знать, например внутренние идентификаторы, скрываются.
Однако скрытие аргументов создало новую проблему. Все аргументы инструмента загрузки были внутренними идентификаторами, поэтому после их скрытия карточка оказывалась пустой. Поэтому мы добавили в инструмент параметры, предназначенные только для отображения. Модель заполняет их понятными пользователю именами, и на карточке отображаются только эти значения.
Тот же принцип мы применили к управлению разрешениями. Роли, необходимые согласно политике инструмента, объявляются, а список инструментов фильтруется в соответствии с разрешениями вызывающей стороны при его возврате. Даже если кто-то напрямую вызывает MCP и обходит этап получения списка, непосредственно перед выполнением та же проверка выполняется ещё раз.
[Один раз при добавлении в список и ещё раз непосредственно перед выполнением]
// The list is filtered by the caller's grants, but the check is repeated here:
// a client that bypasses tools/list must not bypass the policy.
if (StringUtils.hasText(toolPolicy.requiredRole())) {
grant = grantResolver.resolveGrant(
toolPolicy.executionLocation(), toolPolicy.requiredRole());
if (grant == null) {
String reason = "required role not granted: "
+ toolPolicy.requiredRole() + "@" + toolPolicy.executionLocation();
audit(context, mcpTool.name(), riskLevel, false, reason);
return denied(reason);
}
}
2.3. Определения должна хранить сторона, которая их знает
После того как карточка подтверждения заработала, осталось одно место, где мы не применили тот же принцип. Список редактируемых полей, использовавшихся для изменения значений на карточке, был жёстко задан во frontend.
Проблема этой структуры заключается в незаметных сбоях. Если инструмент добавляет поле или изменяет имя поля, а frontend об этом не знает, это поле просто становится недоступным для редактирования. Поскольку ошибки не возникает, так продолжается до тех пор, пока кто-нибудь это не обнаружит.
Причина заключалась в том, что определением владела сторона, отрисовывающая форму. Владелец инструмента — это сторона, которая действительно знает, какие поля существуют, но контракт был выстроен наоборот. Поэтому мы изменили систему: MCP-сервер создаёт формы для каждой комбинации инструмента и типа и отправляет их вместе с предложением подтверждения, а frontend только проверяет их структуру и отрисовывает их. Если они не совпадают, поля переходят в режим «только чтение».
В результате файл схемы формы во frontend сократился со 190 строк до 31, а теперь структура требует изменений только на сервере при изменении полей.
3. Уроки, полученные методом проб и ошибок
Во-первых, когда управление избыточно размещено в двух слоях, внешний слой перехватывает более удачный ответ, который мог бы сформировать внутренний слой. Именно это произошло, когда карточка подтверждения не появилась. Сначала нам следовало определить, что ответственность за управление будет принадлежать одному месту, а остальное будет делегировано. Интуиция «блокировать что-либо в двух местах должно быть безопаснее» верна для блокировки, но неверна, когда система должна представить нечто лучшее, а не просто заблокировать действие.
Во-вторых, определения должна хранить не сторона, которая их отрисовывает, а сторона, которая их знает. Нам следовало усомниться в этой структуре сразу, как только мы увидели, что frontend хранит схему формы. Это был тот же принцип, что и решение централизовать управление подтверждением на сервере, но мы не применили его к форме. Даже после установления единого принципа его слишком узкое применение позволяет воспроизвести такой же тип сбоя в оставшемся пробеле.
В-третьих, необходимо различать то, что следует возвращать как ошибку, и то, что следует возвращать как обычный ответ. Ожидание подтверждения — не сбой, а состояние ожидания решения человека. В тот момент, когда оно выбрасывается как исключение, структура исчезает и остаётся только строка, из-за чего модель интерпретирует эту строку и объясняет её словами. Если модель объясняет предложениями то, что человек должен решить нажатием кнопки, формат ответа обычно выбран неправильно.
В-четвёртых, «функция включена» и «функция работает» — разные вещи. В этот период вызов нативных инструментов был включён, но на практике система каждый раз выбирала другой путь. Двоеточие в ключе карты в конфигурационном файле заставило binding использовать непредусмотренный ключ, поиск ничего не вернул, и система незаметно перешла на резервный путь. Не было ни одной строки в выводе ошибок. В системах с резервными путями сбои незаметны, поэтому необходимо выработать привычку проверять логи и убеждаться, что система действительно использует путь, который, как вам кажется, вы включили.
В-пятых, обсуждая разделение единиц развёртывания, сначала следует изучить границы транзакций и баз данных. Однажды мы рассматривали предложение, согласно которому отдельный процесс MCP зависел бы от существующего сервиса и напрямую вызывал его. Однако слой запросов этого сервиса был связан с транзакциями и сохранением данных, что фактически означало бы повторный запуск сервиса. На диаграмме это выглядело правдоподобно, но открытия одного участка кода на минуту оказалось достаточно, чтобы признать предложение несостоятельным.
4. Результаты реализации
Результаты решения разделить компоненты и проектирования слоя управления можно обобщить следующим образом.
|
Пункт |
При интеграции |
Результат разделения |
|---|---|---|
|
Роль MCP |
JSON-RPC самому себе — причин использовать протокол больше нет |
Фактическое взаимодействие через границы процессов |
|
Управление |
Хост и сервер находятся в одном месте — повторная проверка бессмысленна |
Сервер принимает окончательное решение даже после того, как хост передал запрос дальше |
|
Повторное использование |
Посвящён конкретному хосту |
Несколько клиентов MCP, использующих один и тот же сервер |
|
Ответ на запрос подтверждения |
Исключение → строка → модель повторно запрашивает идентификатор |
Структурированное предложение → фронтенд отображает его в виде карточки |
|
Определение формы |
Фронтенд продублировал его (190 строк, тихий сбой) |
Предоставлено владельцем инструмента (31 строка) |
В ходе проверки в реальных условиях одно предложение — «Загрузить upload-test в хаб Global Gallery» — автоматически вызвало по цепочке listConnectedHubs → listCatalogKollexes → uploadKollexToHub. В карточке подтверждения отображались только знакомые пользователю названия, например «Kollex: upload-test / Хаб для загрузки: Global Gallery», и случаев утечки внутренних идентификаторов не было.
В качестве побочного эффекта за это время мы также решили проблему с размером ответа. Ответ на запрос целиком включал изображение значка, из-за чего размер ответа инструмента достигал 1,1 МБ, однако сервер рекурсивно удалял это поле из ответа, сокращая его примерно до 5 900 символов. Исходные данные, отправленные во фронтенд, оставались без изменений; сокращалось только наблюдение, передаваемое модели.
5. Ограничения и планы на будущее
Во-первых, на этом этапе подтверждение ограничивалось отображением карточки. Фактическая обработка, запускаемая нажатием кнопки подтверждения, то есть выдача и проверка идентификатора подтверждения с последующим продолжением исходного вызова, ещё не была реализована, поэтому на карточке мы честно отображали «Функция подтверждения находится в разработке». Позже мы завершили эту часть, используя механизм elicitation из стандарта MCP, благодаря чему сервер получил возможность напрямую обратиться к пользователю.
Во-вторых, ограничение количества вызовов, определённое как функция шлюза, не было реализовано, а журналы аудита по-прежнему существовали только в журналах приложения. Определить владельца средства контроля и полностью реализовать это средство — разные задачи.
В-третьих, созданный здесь уровень контроля впоследствии ещё дважды подвергался изменениям. Мы сделали проверки авторизации более сложными, затем снова удалили значительную часть этой работы, а владелец подтверждения в конечном итоге переместился ещё ниже по стеку. Мы планируем отдельно задокументировать основания, на которых было принято решение удалить созданное ранее.
Это решение подтвердило, что вопрос о том, сколько проектов создавать, на самом деле был вопросом о том, где разместить ответственность. Само разделение процессов несложно. Сложность заключалась в том, чтобы даже после разделения каждый уровень продолжал отвечать только за свои обязанности. Все три описанных в этой статье опыта проб и ошибок были случаями, когда эти границы однажды оказывались несогласованными.
Ссылки
-
Anthropic, «Спецификация протокола контекста модели», https://modelcontextprotocol.io/specification
-
JSON-RPC Working Group, «Спецификация JSON-RPC 2.0», https://www.jsonrpc.org/specification
-
Spring AI, «Протокол контекста модели (MCP)», https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html
Junny