Инженерия агентов
Контрактные тесты MCP-серверов: как обнаруживать несовместимость до запуска агента
Изменение одного обязательного аргумента, структуры результата или способа сообщать об ошибке способно сломать сценарий, хотя сервер продолжает запускаться, а инструмент остаётся в списке доступных.
Почему проверки доступности недостаточно
Model Context Protocol (MCP) позволяет клиенту обнаруживать инструменты сервера и вызывать их через формализованные сообщения. Но успешная инициализация соединения подтверждает только возможность разговаривать по протоколу. Она не доказывает, что клиент и новая версия сервера одинаково понимают конкретный инструмент.
Рабочий контракт инструмента состоит как минимум из четырёх частей:
- стабильного имени и доступности инструмента в ответе
tools/list; - схемы входных аргументов, включая обязательность, типы и ограничения;
- структуры успешного результата, которую фактически читает клиент;
- семантики отказа: протокольная ошибка, ошибка выполнения инструмента или успешный ответ с предметным отрицательным результатом.
Контрактный тест фиксирует ожидания конкретного потребителя и прогоняет их против каждой кандидатной версии сервера до подключения агента. Это не снимок всего ответа «до последнего пробела», а проверка наблюдаемых свойств, от которых зависит сценарий.
1. Опишите контракт со стороны клиента
Источником ожиданий должен быть не сервер, а код потребителя: какие поля он отправляет, какие читает и какие ветви ошибок обрабатывает. Иначе тест лишь подтвердит, что сервер согласен сам с собой.
Ниже — пример локального манифеста. Имена инструмента и полей условны; замените их контрактом своего проекта.
{
"tool": "records.lookup",
"input": {
"required": ["record_id"],
"properties": {
"record_id": {"type": "string"},
"include_history": {"type": "boolean"}
}
},
"success": {
"content_type": "text",
"payload_required": ["id", "status"]
},
"errors": {
"invalid_arguments": "jsonrpc_error",
"not_found": "tool_error"
}
}
Поле errors здесь является соглашением тестового набора, а не частью MCP. Оно явно фиксирует, какую категорию отказа ожидает клиент.
Что считать несовместимым изменением
| Изменение сервера | Обычный риск для старого клиента |
|---|---|
| Добавлено новое обязательное поле | Старый вызов перестаёт проходить валидацию |
| Поле переименовано или удалено | Переданное значение игнорируется либо отклоняется |
| Тип расширен с одного типа до объединения | Клиент может получить значение, которое не умеет читать |
Сужены enum, диапазон или шаблон |
Ранее допустимый запрос становится недопустимым |
Изменён тип элемента в content |
Парсер результата не находит ожидаемый текст или данные |
| Ошибка инструмента заменена протокольной ошибкой | Меняются повторные попытки, журналирование и ветвление сценария |
Добавление необязательного аргумента обычно обратно совместимо, но только если сервер сохраняет прежнее поведение при его отсутствии. Схема показывает форму запроса, но не доказывает его семантику.
2. Снимите ответы обеих версий
Запускайте старую и кандидатную версии изолированно с одинаковой безопасной конфигурацией и тестовыми данными. Не направляйте контрактные вызовы в продуктивную систему. Для инструментов с побочными эффектами используйте отдельное окружение, режим проверки без записи или специально подготовленные обратимые операции.
На каждой версии выполните один и тот же сценарий:
- инициализация соединения с поддерживаемой версией протокола;
- получение
tools/list; - вызов каждого критичного инструмента с минимальным корректным набором аргументов;
- вызов без обязательного аргумента;
- вызов с заведомо неверным типом;
- предметный отрицательный случай, например отсутствующая запись, если его можно воспроизвести без побочного эффекта.
Точный способ передачи сообщений зависит от транспорта и реализации сервера. Не подменяйте реальный клиент произвольной командой: используйте существующий в проекте тестовый драйвер MCP и сохраните его сырые JSON-ответы в два каталога.
mkdir -p contract-results/baseline contract-results/candidate
# Пример интерфейса вашего проектного драйвера, а не универсальная MCP-команда:
./project-mcp-contract-driver \
--target baseline \
--output contract-results/baseline
./project-mcp-contract-driver \
--target candidate \
--output contract-results/candidate
Если такого драйвера в проекте нет, реализуйте тонкий адаптер поверх уже используемой клиентской библиотеки. Он должен выполнять штатную инициализацию, дожидаться ответа на каждый запрос и сохранять сообщения без секретов. Не записывайте токены, заголовки авторизации и содержимое реальных пользовательских данных.
3. Нормализуйте только нестабильные поля
Идентификатор JSON-RPC-запроса, время выполнения, диагностические метаданные и порядок инструментов могут меняться без нарушения контракта. Их следует исключить или привести к стабильному виду. Но нельзя удалять поле только потому, что оно мешает сравнению.
Пример безопасной нормализации сохранённого tools/list с помощью jq:
jq '
del(.id)
| del(.result._meta)
| .result.tools |= sort_by(.name)
' contract-results/baseline/tools-list.json \
> contract-results/baseline/tools-list.normalized.json
jq '
del(.id)
| del(.result._meta)
| .result.tools |= sort_by(.name)
' contract-results/candidate/tools-list.json \
> contract-results/candidate/tools-list.normalized.json
Перед применением проверьте, что _meta действительно не читается вашим клиентом. Если клиент использует эти сведения, они являются частью потребительского контракта.
4. Сравните схемы по правилам совместимости
Обычный текстовый diff полезен для обзора, но не умеет рассуждать о совместимости JSON Schema. Например, перестановка свойств ничего не меняет, а добавление одного элемента в required ломает старые запросы.
Минимальный анализ для каждого используемого инструмента должен отвечать на вопросы:
- инструмент всё ещё присутствует под тем же именем;
- все аргументы, отправляемые клиентом, остаются разрешёнными;
- ни один новый аргумент не стал обязательным;
- тип каждого отправляемого значения остаётся допустимым;
- ограничения строк, чисел, массивов и перечислений не отвергают существующие фикстуры;
- правила для дополнительных свойств не стали строже для фактических запросов клиента.
Сначала просмотрите структурную разницу:
diff -u \
contract-results/baseline/tools-list.normalized.json \
contract-results/candidate/tools-list.normalized.json
Затем прогоните сохранённые запросы клиента через новую схему и сам новый сервер. Проверка только по схеме недостаточна: сервер может выполнять дополнительную валидацию в коде или трактовать прежнее значение иначе.
5. Проверяйте успешный ответ как потребитель
Не фиксируйте полное текстовое содержимое, если агенту важны только отдельные машинно-читаемые поля. И наоборот, если клиент разбирает JSON, помещённый в текстовый элемент результата, этот внутренний JSON тоже становится контрактом.
Для каждого успешного вызова проверьте:
- ответ относится к исходному запросу;
- присутствует ожидаемый
result, а не протокольная ошибка; - результат содержит поддерживаемый клиентом вид контента;
- обязательные для сценария поля существуют и имеют ожидаемые типы;
- клиентский парсер действительно читает ответ без исключения;
- неизвестные дополнительные поля не ломают строгую десериализацию.
Лучший оракул здесь — настоящий код декодирования из клиента. Вынесите его в тестируемую функцию и передайте ей ответ кандидатного сервера. Так тест обнаружит расхождение между схемой «на бумаге» и фактическими ожиданиями приложения.
Не сравнивайте дословно человекочитаемые формулировки, если клиент их не интерпретирует. Такие снимки создают шум и маскируют важные изменения структуры.
6. Зафиксируйте семантику ошибок
Отказ следует классифицировать до проверки текста сообщения. Для клиента принципиально различаются:
- ошибка уровня JSON-RPC — запрос не может быть корректно обработан на протокольном уровне;
- ошибка выполнения инструмента — вызов инструмента состоялся, но его выполнение завершилось ошибкой; клиент должен распознать соответствующий признак в результате;
- успешный предметный результат — инструмент отработал корректно и сообщил, например, что совпадений нет.
Конкретный числовой код, структуру data и содержимое результата фиксируйте только там, где на них опирается клиент. Для каждого тестового случая полезна таблица ожиданий:
| Случай | Ожидаемая категория | Проверка клиента |
|---|---|---|
| Нет обязательного аргумента | Заранее закреплённая ошибка некорректного запроса | Не повторять вызов без изменения аргументов |
| Аргумент неверного типа | Та же категория на обеих версиях | Показать диагностируемую причину |
| Тестовый объект не найден | Предметный результат или ошибка инструмента — согласно контракту проекта | Выбрать правильную ветвь сценария |
| Внутренняя контролируемая неисправность | Ошибка инструмента без утечки внутренних данных | Применить только разрешённую политику повтора |
Не утверждайте, что один способ сообщать «не найдено» универсально правильный. Важно, чтобы он был явно выбран, стабилен и одинаково понимался сервером и клиентом.
7. Поставьте проверку перед развёртыванием
Контрактный этап должен получать два неизменяемых артефакта: поддерживаемую базовую версию и кандидата. Результат проверки привязывают к версиям сервера, клиента и набору контрактных фикстур.
Пример последовательности для CI, не привязанный к конкретной системе автоматизации:
set -eu
./project-mcp-contract-driver \
--target baseline \
--output contract-results/baseline
./project-mcp-contract-driver \
--target candidate \
--output contract-results/candidate
./project-contract-check \
--client-contract contracts/client-contract.json \
--baseline contract-results/baseline \
--candidate contract-results/candidate
Команды project-mcp-contract-driver и project-contract-check обозначают компоненты вашего репозитория; это не названия существующих внешних продуктов. Проверка должна завершаться ненулевым кодом при несовместимости и печатать путь до конкретного инструмента и свойства, например:
INCOMPATIBLE tools[records.lookup].inputSchema.required
candidate added required property: tenant_id
Не обновляйте базовый снимок автоматически после провала. Такое обновление превращает несовместимость в принятую норму без решения владельца клиента.
Как проверить, что защита действительно работает
Проведите контролируемую мутацию только в тестовой копии сервера или сохранённой схеме. Последовательно внесите три изменения:
- добавьте обязательный аргумент;
- удалите поле, которое читает клиентский парсер;
- замените ожидаемую ошибку инструмента протокольной ошибкой.
Для каждой мутации контрактный этап обязан завершиться до запуска агента и назвать нарушенное ожидание. Затем верните исходную копию и убедитесь, что проверка снова проходит.
Итог можно считать убедительным, если выполняются все условия:
- критичные инструменты найдены и имеют совместимые входные схемы;
- все сохранённые формы запросов принимаются кандидатом;
- ответы проходят реальный клиентский декодер;
- категории ошибок совпадают с матрицей;
- мутационные проверки гарантированно вызывают отказ этапа;
- агент не запускается при отрицательном результате.
Типовые ошибки
Сравнивать только tools/list
Объявленная схема может остаться прежней, тогда как реализация начнёт иначе трактовать аргументы или возвращать другую структуру. Нужны реальные вызовы.
Использовать серверный тест как контракт клиента
Серверные тесты проверяют намерения автора сервера. Они не показывают, какие поля жёстко ожидает уже развёрнутый клиент.
Снимать полный ответ без нормализации
Случайные идентификаторы и диагностические метаданные создают ложные падения. Нормализуйте только доказанно незначимые поля.
Проверять лишь успешный путь
Изменение категории ошибки часто опаснее изменения текста результата: оно влияет на повторные попытки и выбор следующего действия агентом.
Считать любое расширение схемы безопасным
Новое необязательное поле безопасно только при сохранении старого поведения. Новый вариант ответа может попасть в старый клиент и сломать строгий декодер.
Включать реальные секреты и данные
Контрактному набору достаточно синтетических идентификаторов и изолированных данных. Сырые трассировки перед публикацией как артефакта необходимо очищать.
Ограничения методики
Контрактные тесты не доказывают корректность всей бизнес-логики, безопасность сервера или устойчивость под нагрузкой. Они также не гарантируют совместимость с запросами, отсутствующими в манифесте и корпусе фикстур.
Недетерминированные инструменты требуют проверки инвариантов, а не точного результата. Для потоковых ответов, уведомлений, отмены, тайм-аутов и конкурентных вызовов нужны отдельные сценарии. Инструменты с внешними побочными эффектами следует проверять в изолированной среде; имитация зависимости способна скрыть реальное расхождение её контракта.
Наконец, совместимость двунаправленна. Если новый клиент должен работать со старым сервером, повторите ту же матрицу для этой пары. Проверка «старый клиент — новый сервер» не отвечает на обратный вопрос.
Рабочая схема
- Извлечь ожидания из действующего клиентского кода.
- Зафиксировать критичные аргументы, поля ответа и категории ошибок.
- Получить одинаковый набор ответов от базовой и кандидатной версий.
- Нормализовать только незначимые различия.
- Проверить совместимость схем и прогнать реальные формы запросов.
- Передать ответы настоящему клиентскому декодеру.
- Проверить отрицательные сценарии и политику повторов.
- Останавливать развёртывание до запуска агента при любом нарушении.
Главный результат — не коллекция снимков, а исполняемое соглашение между конкретными версиями клиента и сервера. Оно превращает незаметное изменение MCP-инструмента в локальный, объяснимый отказ этапа сборки.