Инженерия агентов

Эволюция схем MCP-инструментов без поломки агентов

Продвинутый уровень · до 8 минут · результат: правила совместимости, версионирование и контрактные тесты

Переименование одного параметра может выглядеть как локальный рефакторинг, но для сохранённого промпта или оркестратора это изменение публичного контракта. Разберём, как развивать схемы инструментов постепенно, проверяемо и с возможностью отката.

Почему схема — это контракт

Model Context Protocol (MCP) позволяет серверу объявлять инструменты и описывать их входные параметры. Эту схему видят не только разработчики: на неё опираются модель, сохранённые промпты, программные оркестраторы, валидаторы и журналы повторного выполнения.

Если поле query внезапно станет search_text, старый вызов может быть отклонён ещё до запуска обработчика. Если необязательное поле станет обязательным, ранее корректные аргументы перестанут проходить валидацию. Даже расширение списка допустимых значений способно сломать потребителя, который использует исчерпывающий switch.

Практическое правило: опубликованное имя инструмента вместе с его входной и выходной схемами следует считать API, даже если инструмент пока используется только внутри одной команды.

1. Зафиксируйте правила совместимости

Разделите изменения на совместимые, условно совместимые и несовместимые. Это не абсолютная классификация: итог зависит от поведения конкретных потребителей. Таблица ниже — рекомендуемая политика для проекта, а не свойство протокола.

Изменение Риск Безопасная стратегия
Добавить необязательное входное поле Низкий Задать прежнее поведение при отсутствии поля
Переименовать поле Высокий Временно принимать старое и новое имя
Сделать поле обязательным Высокий Добавить новую версию инструмента либо сначала ввести значение по умолчанию
Удалить поле или значение перечисления Высокий Новая старшая версия и период миграции
Добавить поле в результат Средний Проверить, что потребители допускают неизвестные поля
Изменить тип, формат или смысл поля Высокий Новое поле или новая версия; не менять семантику молча

Добавьте политику в репозиторий рядом с кодом сервера. Минимальный набор правил:

  1. Старые корректные аргументы остаются корректными в пределах заявленного окна поддержки.
  2. Старое имя поля не удаляется до завершения миграции известных потребителей.
  3. Если переданы старое и новое имена одновременно, поведение определено заранее.
  4. Значения по умолчанию применяются сервером, а не только описываются текстом.
  5. Несовместимое изменение получает новую старшую версию инструмента.
  6. Изменения входной и выходной схем проходят контрактные тесты.

2. Переименовывайте через период двойного чтения

Допустим, инструмент search_documents принимал query, а команда хочет перейти на search_text. Не заменяйте поле одним коммитом. Сначала объявите оба имени необязательными, затем нормализуйте их в один внутренний параметр.

Пример схемы переходного периода:

{
  "type": "object",
  "properties": {
    "search_text": {
      "type": "string",
      "minLength": 1,
      "description": "Текст поиска."
    },
    "query": {
      "type": "string",
      "minLength": 1,
      "description": "Устаревшее имя search_text; поддерживается до окончания миграции."
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50,
      "default": 10
    }
  },
  "anyOf": [
    { "required": ["search_text"] },
    { "required": ["query"] }
  ],
  "additionalProperties": false
}

Пример нормализации в обработчике на TypeScript:

type SearchArgs = {
  search_text?: string;
  query?: string;
  limit?: number;
};

function normalizeSearchArgs(args: SearchArgs) {
  if (
    args.search_text !== undefined &&
    args.query !== undefined &&
    args.search_text !== args.query
  ) {
    throw new Error(
      "Переданы конфликтующие search_text и query"
    );
  }

  const searchText = args.search_text ?? args.query;

  if (searchText === undefined || searchText.length === 0) {
    throw new Error("Требуется search_text или query");
  }

  return {
    searchText,
    limit: args.limit ?? 10
  };
}

При конфликте двух имён лучше вернуть явную ошибку, чем молча выбрать одно. Приоритет нового поля допустим только тогда, когда он зафиксирован в контракте и протестирован. Телеметрия может считать использование устаревшего имени, но не должна записывать содержимое потенциально чувствительных аргументов.

3. Не меняйте обязательность без миграционного слоя

Пусть инструмент отправки отчёта раньше принимал только report_id, а теперь ему нужен format. Простое добавление format в required немедленно делает старые вызовы некорректными.

Если прежнее поведение имеет однозначный эквивалент, оставьте поле необязательным и примените серверное значение по умолчанию:

Пример:

{
  "type": "object",
  "properties": {
    "report_id": {
      "type": "string",
      "minLength": 1
    },
    "format": {
      "type": "string",
      "enum": ["pdf", "html"],
      "default": "pdf"
    }
  },
  "required": ["report_id"],
  "additionalProperties": false
}

Аннотация default в схеме сама по себе не гарантирует, что валидатор подставит значение. Обработчик обязан реализовать format ?? "pdf" либо проект должен явно настроить и протестировать подстановку.

Если безопасного значения по умолчанию нет, создайте новый контракт. Например, оставьте send_report на время миграции и опубликуйте send_report_v2, где format обязателен. Имя версии должно быть стабильным и понятным потребителям.

4. Версионируйте контракт, а не реализацию

Новый номер сборки сервера не требует нового имени инструмента. Версия контракта нужна тогда, когда старый корректный вызов нельзя сохранить без двусмысленности, опасного значения по умолчанию или изменения смысла.

  • Без новой старшей версии: исправление описания, расширение внутренней производительности, добавление необязательного поля с прежним поведением.
  • С переходным периодом: переименование через алиас, замена устаревшего необязательного поля, добавление нового варианта результата для терпимых потребителей.
  • С новой старшей версией: новый обязательный аргумент без безопасного значения по умолчанию, смена типа, единиц измерения, семантики или структуры результата.

Для каждого инструмента ведите краткую запись контракта: текущее имя, поддерживаемые старшие версии, дата объявления устаревания, условие удаления старой версии и ответственный владелец. Не назначайте дату удаления только по календарю: подтвердите, что известные потребители мигрировали и наблюдаемое использование старого контракта прекратилось.

5. Добавьте воспроизводимые контрактные тесты

Контрактный тест должен проверять опубликованную схему и поведение границы инструмента. Он не обязан запускать модель или настоящий внешний сервис. Зафиксируйте входные примеры без секретов и тестируйте нормализатор либо обработчик с локальными заглушками.

Пример структуры фикстур:

contracts/
  search_documents/
    legacy-query.json
    current-search-text.json
    conflicting-aliases.json
    expected-normalized.json

Пример фикстуры старого вызова:

{
  "query": "правила хранения",
  "limit": 5
}

Пример теста на встроенном тестовом модуле Node.js:

import test from "node:test";
import assert from "node:assert/strict";
import { normalizeSearchArgs } from "../src/search.js";

test("сохраняет поддержку старого параметра query", () => {
  assert.deepEqual(
    normalizeSearchArgs({ query: "правила хранения", limit: 5 }),
    { searchText: "правила хранения", limit: 5 }
  );
});

test("принимает новый параметр search_text", () => {
  assert.deepEqual(
    normalizeSearchArgs({ search_text: "правила хранения" }),
    { searchText: "правила хранения", limit: 10 }
  );
});

test("отклоняет конфликтующие алиасы", () => {
  assert.throws(
    () => normalizeSearchArgs({
      query: "старое значение",
      search_text: "новое значение"
    }),
    /конфликтующие/
  );
});

Это демонстрационный код: пути и экспорт нужно адаптировать к проекту. Он не утверждает наличие конкретного MCP-клиента или тестовой библиотеки в вашем репозитории.

Контрактный набор должен охватывать:

  1. последний поддерживаемый старый вызов;
  2. текущий рекомендуемый вызов;
  3. отсутствие необязательных полей;
  4. граничные значения и неверные типы;
  5. одновременную передачу старого и нового имён;
  6. неизвестные поля согласно выбранной политике;
  7. стабильную форму успешного результата и ошибки.

6. Сравнивайте схемы до публикации

Храните каноническую опубликованную схему в системе контроля версий. Перед выпуском сериализуйте текущую схему детерминированно и просматривайте разницу. Снимок не заменяет поведенческие тесты, но делает незапланированное удаление поля заметным.

Безопасные команды только для просмотра изменений:

git diff -- contracts/
git status --short
git diff --check

Если проект использует Node.js и тестовый сценарий уже объявлен в package.json, его можно запустить без передачи секретов:

npm test

Не копируйте команду в автоматизацию вслепую: сначала проверьте содержимое соответствующего сценария. Контрактный тест не должен обращаться к производственной системе, отправлять сообщения или изменять реальные данные.

7. Выпускайте изменение по этапам

  1. Инвентаризация. Найдите сохранённые промпты, конфигурации оркестраторов, фикстуры и код, где используется старое имя.
  2. Расширение. Добавьте новое поле, продолжая принимать старое; реализуйте нормализацию и конфликтную ошибку.
  3. Проверка. Запустите старые и новые контрактные фикстуры против одной версии обработчика.
  4. Миграция. Обновите управляемые потребители и примеры, не удаляя совместимость на сервере.
  5. Наблюдение. Считайте обращения к устаревшему пути без сохранения содержимого аргументов.
  6. Удаление. Удаляйте алиас только в заранее объявленной несовместимой версии после подтверждённой миграции.

Для отката сохраняйте предыдущую версию схемы и обработчика как единый выпуск. Откат только схемы опасен: объявленный контракт может разойтись с тем, что реально принимает код.

Проверка результата

Миграционный механизм готов, если все утверждения ниже подтверждаются автоматическими проверками или наблюдаемыми артефактами:

  • фикстура старого вызова проходит валидацию и даёт прежнюю семантику;
  • новое имя параметра работает и присутствует в опубликованной схеме;
  • обработчик действительно применяет заявленное значение по умолчанию;
  • конфликт старого и нового имён возвращает стабильную, понятную ошибку;
  • разница схем не содержит необъяснённых удалений, смен типов или новых обязательных полей;
  • тесты не требуют производственных учётных данных и не выполняют внешних побочных действий;
  • существует явное условие удаления устаревшего поля или версии.

Типовые ошибки

«Модель прочитает новое описание и адаптируется»
Сохранённый программный вызов не обязан заново интерпретировать описание. Совместимость должна обеспечиваться схемой и обработчиком.
Алиас добавлен только в код
Если вход предварительно валидируется по схеме, старое поле будет отклонено до запуска кода. Переход нужно отражать и в схеме, и в обработчике.
default объявлен, но не применяется
Описание значения по умолчанию и его фактическая подстановка — разные вещи. Проверяйте результат вызовом без этого поля.
Новое поле молча побеждает старое
Конфликтующие значения скрывают ошибки миграции. Лучше отклонить вызов с диагностикой.
Снимок схемы считается достаточным тестом
Снимок показывает структуру, но не доказывает сохранение семантики, значений по умолчанию и формы ошибок.
Старая версия удаляется после условной даты
Календарный срок не доказывает миграцию потребителей. Нужны данные использования и подтверждение владельцев.

Ограничения подхода

Двойное чтение увеличивает сложность схемы и обработчика. Его следует считать временным состоянием с владельцем и критерием завершения. Контрактные тесты защищают только зафиксированные сценарии: они не доказывают, что любая модель сформирует правильный вызов.

Совместимость структуры не гарантирует совместимость смысла. Если поле timeout раньше означало секунды, а теперь миллисекунды, прежний JSON останется валидным, но поведение станет неверным. Такие изменения требуют нового поля или новой старшей версии.

Наконец, возможность принять старый вызов не означает, что его безопасно выполнять. При изменении прав доступа, побочных эффектов или требований подтверждения следует проектировать отдельный контракт безопасности, а не маскировать различия значениями по умолчанию.

Короткий чек-лист перед выпуском

  • Изменение классифицировано по совместимости.
  • Старый вызов сохранён как контрактная фикстура.
  • Схема и обработчик поддерживают одинаковый переходный контракт.
  • Для новых обязательных данных выбран безопасный default либо новая версия.
  • Конфликты алиасов обрабатываются явно.
  • Входная и выходная схемы проверены на незапланированные изменения.
  • Есть план наблюдения, отката и удаления устаревшей версии.