# Как дрейф схемы MCP ломает долгоживущих агентов

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

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

Model Context Protocol позволяет серверам сообщать об изменениях списка инструментов. Но старый кэш клиента от этого не превращается в новый контракт, уже предложенные моделью вызовы инструментов не исправляются, а безопасность старого запроса не определяется автоматически. Это инженерные решения. Принимайте их осознанно и проверяйте, пока сессия еще активна.

## Описание инструмента является частью состояния сессии

Схема инструмента, это исполняемый контекст. Агент использует имя, описание, схему входных данных, аннотации и иногда схему результата, чтобы решить, какое действие запросить. Многие клиенты также разбирают `tools/list` в локальные структуры, компилируют валидаторы или помещают краткое описание инструмента в контекст модели. Ни одна из этих копий не изменяется только потому, что сервер развернул другую версию.

Это важно даже тогда, когда протокол передачи работает точно по замыслу. Допустим, клиент начал работу с таким инструментом:

```json
{
  "name": "deploy_preview",
  "description": "Deploy the current branch to a preview environment.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "branch": { "type": "string" }
    },
    "required": ["branch"],
    "additionalProperties": false
  }
}
```

Через час сервер меняет операцию и требует явное поле `region`. Новый клиент видит новую схему и может его передать. Старый клиент по-прежнему считает `{\"branch\":\"fix-login\"}` полным запросом.

Здесь есть три разных состояния, которые команды часто смешивают:

1. **Объявленная схема** это то, что сервер сейчас возвращает через `tools/list`.
2. **Снимок схемы клиента** это то, что конкретный клиент сохранил при последнем получении списка инструментов.
3. **Контракт выполнения** это то, что сервер примет и выполнит после получения `tools/call`.

Сервер может немедленно обновить первое состояние. Он не может считать, что второе уже обновилось. Для третьего состояния нужно выбрать правила обработки.

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

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

## `tools/list_changed` сообщает об изменении, но не синхронизирует его

Спецификация MCP определяет `notifications/tools/list_changed` для серверов, у которых меняется список инструментов. Сервер отправляет уведомление, после чего клиент может заново запросить инструменты через `tools/list`. Спецификация также предусматривает поддержку уведомлений об изменении списка в возможностях сервера, согласованных при инициализации.

Это полезный механизм, но слово «может» здесь многое меняет. У уведомления нет ответа. По одному уведомлению сервер не узнает, получил ли его клиент, обновился ли список, изменился ли кэш инструментов или успела ли модель составить вызов по старому описанию.

Считайте уведомление сигналом для инвалидации, а не барьером синхронизации.

Клиент, который хорошо справляется с дрейфом, после получения уведомления должен сделать четыре вещи:

- Снова запросить `tools/list` и атомарно заменить соответствующие локальные определения.
- Сохранить старый снимок на достаточно долгое время, чтобы связать уже запланированный вызов со схемой, на основе которой он создан.
- Проверить каждый вызов в очереди по обновленной схеме перед отправкой.
- Вернуть модели понятную ошибку, которую можно исправить, если вызов из старого контекста больше нельзя выполнить.

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

Серверу нужна такая же дисциплина. При изменении инструмента отправляйте уведомление после того, как новый ответ `tools/list` уже готов. Не объявляйте новый контракт, оставляя старый процесс обрабатывать вызовы произвольное время. Если архитектура развертывания допускает такую ситуацию, добавьте в ответ явную ревизию контракта и отклоняйте вызовы, которые попали на обработчик с несовместимым поведением.

Уведомление также не помогает клиентам без такой поддержки, клиентам, потерявшим соединение во время события, и вызовам через посредника с собственным кэшем. Совместимость на уровне `tools/call` все равно необходима. Если сервер работает только при идеальной реакции каждого клиента на уведомление, в продакшене он не работает.

## Изменения входных данных приводят к разным видам поломок

Добавление поля не относится к одной-единственной категории изменений. Риск зависит от того, меняет ли поле проверку, смысл или полномочия.

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

Добавление обязательного `region` в `deploy_preview` устроено иначе. Старый вызывающий код больше не проходит проверку. Можно отклонить запрос или подставить значение по умолчанию. Первый вариант прерывает работу агента, но честно описывает ситуацию. Второй допустим только тогда, когда это значение всегда было предназначенным для данного репозитория регионом и не может перенаправить развертывание в более чувствительную среду.

Изменение смысла поля опаснее, чем добавление обязательного поля. Представьте инструмент, который сначала принимает `project` как понятный человеку идентификатор проекта. Позже сервер решает, что `project` должен быть непрозрачным идентификатором организации. Старый агент все еще может отправить `payments`, а сервер разрешит эту строку в неожиданном пространстве имен. Проверка пройдет, запрос выполнится, но действие окажется неправильным. Это семантическая поломка, и она опаснее явной ошибки проверки.

Удаление входного поля требует такой же осторожности. `additionalProperties: false` в JSON Schema делает проблему видимой. Старый клиент отправляет ранее допустимый аргумент и получает ошибку. Если сервер молча игнорирует удаленное поле, вызов может завершиться успехом с другим смыслом, чем ожидала модель.

Популярный совет «быть либеральным в том, что принимаешь» плохо подходит для инструментов, выполняющих действия. Он стал распространенным, потому что помогает интеграциям переживать небрежные изменения. Для настройки отображения такая терпимость может быть безвредной. Для HTTP-запроса с учетными данными, команды SSH, развертывания, удаления или платежа снисходительный разбор превращает неоднозначный запрос в догадки на стороне сервера.

Используйте адаптер совместимости только тогда, когда можете точно описать его поведение. Например:

```ts
function normalizeDeployArgs(raw: unknown) {
  if (!isPlainObject(raw)) {
    throw executionError("Expected an object for deploy_preview.");
  }

  if (typeof raw.branch !== "string" || raw.branch.length === 0) {
    throw executionError("The branch field must be a non-empty string.");
  }

  if (raw.region === undefined) {
    return { branch: raw.branch, region: "us-east-preview", schemaRevision: 1 };
  }

  if (raw.region !== "us-east-preview" && raw.region !== "eu-preview") {
    throw executionError("region must be us-east-preview or eu-preview.");
  }

  return { branch: raw.branch, region: raw.region, schemaRevision: 2 };
}
```

У этого адаптера есть приемлемое свойство: старый запрос приводит к тому же месту предварительного развертывания, что и раньше. Он был бы неприемлем, если бы `us-east-preview` оказался лишь удобной догадкой после изменения владельца учетной записи.

При несовместимом изменении возвращайте ошибку, которой агент может воспользоваться. Укажите ожидаемую ревизию инструмента, назовите отсутствующее или устаревшее поле и попросите клиента обновить список инструментов. Не возвращайте расплывчатое сообщение вроде «неверный ввод». Модели пытаются повторять такие ошибки с небольшими вариациями. Понятная ошибка дает шанс на исправление.

## Изменения формы результата могут испортить следующее решение

Команды внимательно относятся к проверке входных данных, потому что плохой запрос останавливается на сервере. Изменения результата проверяют менее тщательно, поскольку действие уже завершилось. Для агентов это неверный приоритет. Результат часто служит основанием для следующего вызова.

Представьте исходный результат `create_issue`:

```json
{
  "issue": {
    "id": "I-482",
    "url": "https://tracker.example/issues/I-482",
    "state": "open"
  }
}
```

Агент может извлечь `issue.id`, сохранить его в рабочей памяти и позже вызвать `add_comment` с этим идентификатором. Если обновленный сервер переименует `id` в `issueId`, поместит результат под `data` или изменит `state` со строки на объект, следующий шаг агента может завершиться ошибкой далеко от исходного вызова. Хуже того, текстовый запасной вариант может по-прежнему содержать правдоподобную фразу, и модель начнет придумывать идентификатор по прозе.

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

Здесь важна работа MCP со схемами JSON Schema 2020-12. Более поздние рекомендации протокола явно задают диалект, а схемы результата могут описывать больше, чем только объекты. Это расширяет выразительность, но не дает права бездумно менять результат активной сессии. Клиент может проверять результаты с помощью конкретной версии стандарта, сгенерированного типа или декодера, который не допускает новый вариант объединения или форму массива.

Для развития формы результата придерживайтесь таких правил:

1. Сначала добавляйте поля, а уже потом переименовывайте или удаляйте их.
2. Сохраняйте смысл полей, особенно идентификаторов, статусов и временных меток.
3. Добавляйте в структурированный вывод поле `schema_revision` или `result_version`, если одновременно должны существовать несколько интерпретаций.
4. При ошибке выполнения возвращайте полный структурированный объект ошибки, а не заменяйте успешный результат неструктурированным извинением.
5. Удаляйте старую форму только после завершения старых сессий или опубликованного окна миграции.

Поле с ревизией результата не служит украшением. Оно позволяет клиенту отличить «сервер вернул неполный старый ответ» от «сервер вернул новый ответ, в котором необязательное поле отсутствует». Это различие важно, когда агент решает, повторить ли запрос, спросить пользователя или перейти к значимому действию.

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

## Тестировать нужно старый план против нового сервера

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

Постройте тест на двух фикстурах сервера. Фикстура A объявляет старое определение инструмента. Фикстура B объявляет новое определение и управляет обработкой старых аргументов. Клиент остается подключенным во время переключения. Если сервер не умеет менять поведение без перезапуска, добавьте детерминированный переключатель в реестр инструментов, вместо того чтобы воспроизводить тайминг средствами системы развертывания.

Вот минимальный полезный сценарий:

```text
1. Клиент инициализируется и получает возможность tools.listChanged.
2. Клиент вызывает tools/list и сохраняет deploy_preview, ревизия 1.
3. Клиент готовит аргументы: {"branch":"fix-login"}.
4. Сервер переключается на ревизию 2, где для новых клиентов обязателен region.
5. Сервер отправляет notifications/tools/list_changed.
6. Клиент отправляет уже подготовленный вызов ревизии 1.
7. Клиент обновляет tools/list.
8. Клиент повторяет вызов только при разрешении политики исправления.
9. Клиент вызывает ревизию 2 с {"branch":"fix-login","region":"us-east-preview"}.
```

Тест должен проверять не только успех или ошибку. Записывайте фактические сообщения JSON-RPC, нормализованные сервером аргументы, вызовы заглушки внешнего эффекта и журнал событий клиента. Сервер, который возвращает аккуратную ошибку, но уже начал внешнее развертывание, тест не прошел.

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

Компактная таблица помогает обсуждать ожидаемое поведение:

| Событие дрейфа | Поведение старого клиента | Поведение сервера | Внешний эффект |
| --- | --- | --- | --- |
| Добавлено необязательное `label` | Вызывает без `label` | Применяет прежнее значение по умолчанию | Один ожидаемый запрос |
| Добавлен обязательный `region` с безопасным историческим значением по умолчанию | Вызывает без `region` | Подставляет стабильное значение | Один ожидаемый запрос |
| Добавлена обязательная область подтверждения | Вызывает без области | Возвращает исправимую ошибку выполнения | Запрос не отправляется |
| Изменен смысл `project` | Передает старый `project` | Отклоняет как несовместимый | Запрос не отправляется |
| Добавлено поле результата | Разбирает прежние поля | Возвращает старые и новое поле | Дополнительного действия нет |
| Удален идентификатор результата | Пытается выполнить следующий зависимый вызов | Клиент останавливается и сообщает об ошибке контракта | Зависимый запрос не отправляется |

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

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

## Логике исправления клиента нужна граница для повторных попыток

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

Автоматически обновляйте схему и повторяйте вызов только при одновременном выполнении всех условий:

- Сервер явно указывает на устаревшую ревизию схемы или отсутствующее поле.
- Обновленное определение инструмента содержит однозначное безопасное значение по умолчанию или детерминированное преобразование.
- Исходное действие остается в тех же границах цели и разрешений.
- Первая попытка не вызвала внешнего побочного эффекта.

Во всех остальных случаях нужно новое решение. Если ревизия добавляет `environment`, `account_id`, `repository`, `host`, `user` или текст подтверждения, автоматический повтор может расширить действие или направить его в другую цель. Даже если модель способна предположить вероятный ответ, ей следует получить свежий контекст или одобрение человека.

Разделяйте идентификатор запроса и идентификатор повторной попытки. Если вызов инструмента мог дойти до внешней системы до получения клиентом ответа, нельзя бездумно отправлять его снова после обновления схемы. Используйте токен идемпотентности, если внешний API это поддерживает. Для SSH, где общего механизма идемпотентности нет, проектируйте команды так, чтобы повторное выполнение было безопасным или обнаруживаемым. Миграция схемы не подходит для обнаружения дублей, создаваемых тайм-аутом.

Хорошая ошибка клиента дает модели информацию, но не подменяет ее ложной инструкцией. Например:

```json
{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "deploy_preview rejected this request because its input contract changed. Refresh tools before retrying. The current schema requires branch and region. No deployment was started."
    }
  ],
  "structuredContent": {
    "error_code": "STALE_TOOL_SCHEMA",
    "tool": "deploy_preview",
    "required_action": "refresh_tools",
    "side_effect_started": false,
    "current_revision": 2
  }
}
```

Точная оболочка ошибки остается вашим решением, но факты обязательны. Укажите, началось ли действие. Укажите, поможет ли обновление. Укажите текущую ревизию, если сервер ее публикует. Модель может использовать эти сведения. Общая транспортная ошибка не может.

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

## При проверке безопасности нужно учитывать смысл, а не только секреты

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

Представьте инструмент `run_report`, который изначально принимает `{\"team\":\"sales\"}`. Сервер меняет его так, чтобы `target` мог обозначать команду, сохраненный отчет или произвольный запрос. Старый агент все еще отправляет `team`. Если адаптер превращает это в `target: \"sales\"`, что именно было разрешено? Команда? Отчет с названием sales? Псевдоним запроса? Сервер создал неоднозначность на границе действия. Отклоните такой запрос и опубликуйте отдельный инструмент или явный путь миграции.

Учетные данные повышают риски. Агент, который напрямую хранит API-ключи, может смешать исправление схемы с обработкой секретов в собственном процессе и журналах. Это дает устаревшему плану больше возможностей причинить вред. Sallyport хранит учетные данные HTTP и SSH в зашифрованном хранилище и сам выполняет действие, не передавая секрет агенту. Это разделение само по себе не делает изменившийся контракт безопасным, но упрощает проверку фактического запроса, точки авторизации и записи аудита.

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

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

Журнал Activity и журнал Sessions в Sallyport показывают, как можно разделять запуск агента и отдельные внешние вызовы. Для тестов дрейфа нужны оба представления: одна запись о том, что запуск был авторизован, и отдельная запись для каждого HTTP- или SSH-действия, которое покинуло машину или не покинуло ее.

## Используйте окна совместимости и намеренно закрывайте их

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

Начните с классификации изменения.

Аддитивное изменение сохраняет старый вызов допустимым и прежний смысл. Оставьте то же имя инструмента, объявите изменение списка и принимайте обе формы, пока активные сессии не завершатся.

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

Семантическое изменение или изменение полномочий требует нового имени инструмента. `deploy_preview` и `deploy_environment` могут использовать одну реализацию, но не должны иметь общий контракт, если первый выбирает известную среду предварительного просмотра, а второй может выбрать продакшен. В списке инструментов это может выглядеть избыточно, но такая цена все равно ниже риска, что агент решит, будто вызвал более узкую операцию.

Удаление должно завершаться ясной ошибкой. Укажите инструмент-замену или сообщите, что возможность исчезла. Не оставляйте имя инструмента, которое ничего не делает. Молчаливый успех опасен для автоматизированной работы: агент фиксирует выполнение, хотя нужный эффект не произошел.

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

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

## Контроль выпуска, который обнаружит дрейф раньше пользователей

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

Для каждого измененного инструмента ответьте на эти вопросы в описании изменения или на выпускном обсуждении:

1. Может ли существующая сессия по-прежнему отправить прежние допустимые аргументы?
2. Если да, сохраняют ли эти аргументы в точности прежний смысл действия?
3. Если нет, происходит ли отклонение до любого внешнего эффекта?
4. Отправляет ли сервер `notifications/tools/list_changed` только после того, как новый список доступен?
5. Может ли клиент объяснить путь исправления, не придумывая отсутствующие полномочия?

Затем превратите ответы в исполняемые проверки. Храните старые и новые фикстуры `tools/list` рядом с тестом. Запускайте последовательность со снимком старого клиента. Проверяйте внешние запросы, а не только ответы MCP. После выпуска миграции сохраните регрессионную фикстуру, потому что следующий рефакторинг может удалить ветку совместимости, а никто не вспомнит, зачем она была нужна.

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