# Дублирующимся вызовам инструментов MCP нужна идентичность выполнения

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

В MCP это легко не заметить, потому что вызов инструмента может проходить через несколько границ: процесс агента, транспорт MCP, шлюз действий и HTTP API или SSH-цель. Соединение может исчезнуть после того, как целевая система приняла работу, но до того, как агент получил результат. Если после этого система отправит вызов повторно, целевая сторона увидит два корректных запроса. У нее нет причин считать второй случайностью.

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

## Переподключение не дает права выполнять действие снова

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

Это различие кажется очевидным, пока кто-то не добавит универсальное промежуточное ПО для повторов под клиентом MCP. Оно видит тайм-аут, сброс соединения или отсутствие ответа. Но оно не понимает, был ли POST запросом на чтение, запись, удаленную команду или необратимую операцию. Оно отправляет байты снова, потому что так часто устроен код повторов для HTTP.

Для чтения вроде `GET /repos/acme/api/branches` это может быть приемлемо. Но `POST /payments`, `DELETE /projects/atlas` или SSH-команда, меняющая рабочий сервер, могут создать второй побочный эффект. Слой инструментов не исправит ситуацию постфактум, если просто вернет модели один результат.

Спецификация транспорта MCP Streamable HTTP прямо разрешает клиентам возобновлять доставку событий от сервера к клиенту с помощью `Last-Event-ID`, если поток прервался. Это механизм восстановления сообщений в потоке. Он не превращает второй запрос `tools/call` JSON-RPC в то же самое выполнение. Документация TypeScript SDK также отделяет токены возобновления от пути запроса и допускает промежуточное ПО клиента вокруг `fetch`. Именно поэтому правило повторов нужно явно записать в коде, а не надеяться, что транспорт все исправит.

Используйте такое правило:

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

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

## Идентификаторы JSON-RPC определяют сообщения, а не долговечные действия

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

Рассмотрим такую пару вызовов:

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

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

Теперь рассмотрим два вызова с разными идентификаторами:

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

```json
{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

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

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

Практическая модель разделяет три идентификатора:

- **Идентификатор связи протокола**: идентификатор JSON-RPC и, если применимо, контекст сессии или потока MCP.
- **Идентификатор выполнения**: созданный сервером идентификатор одной принятой попытки выполнить действие инструмента.
- **Отпечаток намерения**: стабильный дайджест запрошенного эффекта, который помогает найти предыдущее выполнение, когда протокольная связь изменилась.

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

## Хороший отпечаток описывает эффект

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

Сначала создайте каноническую запись действия. Для HTTP-действия она может выглядеть так:

```json
{
  "actor": "signed-process:com.example.agent",
  "tool": "deploy_release",
  "channel": "http",
  "target": "deploy-api.internal.example/releases",
  "credential_ref": "deploy-service",
  "method": "POST",
  "arguments": {
    "service": "catalog",
    "version": "2026.07.22",
    "region": "us-east-1"
  },
  "intent_scope": "run:5f8097"
}
```

Канонизируйте порядок полей, исключайте поля без смыслового значения и нормализуйте известные эквиваленты перед вычислением дайджеста. Если `region` по умолчанию равен `us-east-1`, всегда добавляйте его или всегда исключайте, когда целевая система подставит это значение сама. Если смешивать оба подхода, появятся ложные несовпадения.

Поле `actor` имеет значение. Два разных авторизованных процесса агента с одинаковыми аргументами могут представлять два отдельных намеренных действия. Важен и `credential_ref`. Запрос через одну сервисную учетную запись не обязательно равен запросу к тому же пути с тем же телом, выполненному через другую.

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

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

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

## В записи активности нужны состояния, а не одна строка журнала

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

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

1. **Принято**: шлюз проверил запрос и назначил идентификатор выполнения.
2. **Авторизовано**: необходимое согласование или разрешение сессии допускает действие.
3. **Отправлено**: шлюз передал действие HTTP-клиенту или помощнику SSH.
4. **Результат получен**: пришел ответ целевой системы, код завершения или явное сообщение о сбое доставки.
5. **Результат доставлен**: агент получил результат инструмента, если транспорт позволяет это установить.

Четвертое и пятое состояния нужно разделять. Целевая система может вернуть HTTP 201, а соединение с клиентом MCP прервется до того, как тот увидит ответ. Называть выполнение неудачным из-за сбоя доставки результата было бы неправдой. Если отметить его завершенным, код восстановления сможет сделать что-то полезное: вернуть известный результат или восстановить его, не отправляя запрос заново.

Вот такую форму записи я хочу видеть во время инцидента:

```json
{
  "execution_id": "act_01J4K8J7DX7V",
  "fingerprint": "hmac-sha256:4a1e...d90c",
  "tool": "deploy_release",
  "actor": "signed-process:com.example.agent",
  "target": "deploy-api.internal.example/releases",
  "state": "completed_result_not_delivered",
  "accepted_at": "2026-07-22T14:03:18Z",
  "dispatched_at": "2026-07-22T14:03:19Z",
  "completed_at": "2026-07-22T14:03:25Z",
  "target_status": 201,
  "result_reference": "result_01J4K8JFM2"
}
```

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

Журнал Activity в Sallyport фиксирует отдельные вызовы, а журнал Sessions фиксирует запуски агентов. Для такого расследования это разделение полезно: запуск показывает, какой процесс агента существовал, а запись вызова показывает, пересекло ли конкретное действие внешнего мира границу отправки. Цепочку аудита также можно проверить офлайн командой `sp audit verify`, что помогает доказать, что запись не переписали после инцидента.

## Считайте неизвестный результат отдельным состоянием

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

«Неизвестно» не означает, что система ничего не сделала. Это означает, что система не может доказать, приняла ли целевая сторона действие. Тайм-аут до того, как байты покинули процесс, часто безопасно повторить. Тайм-аут после передачи тела HTTP-запроса операционной системе является другим событием. Еще сложнее разрыв SSH-соединения после запуска команды удаленной оболочкой, потому что удаленная команда может продолжить работу после завершения локального процесса.

Перед выбором способа восстановления классифицируйте каждый вызов инструмента:

| Тип действия | Пример | Действие по умолчанию при неизвестном результате |
|---|---|---|
| Только чтение | Получить состояние сборки | Повторить в обычных пределах |
| Идемпотентная запись | Перевести именованный ресурс в заданное состояние | Повторить с тем же идентификатором идемпотентности |
| Условная запись | Обновить только при совпадении версии | Запросить состояние и повторять только если условие все еще выполняется |
| Необратимое действие | Отправить платеж, отозвать доступ, сменить учетные данные | Остановиться и запросить явную проверку |
| Удаленная команда | Выполнить миграцию по SSH | Проверить долговечную отметку или остановиться для проверки |

HTTP-метод сам по себе не определяет эту таблицу. `PUT` часто называют идемпотентным, но плохо спроектированный endpoint может при каждом запросе отправлять уведомление, запускать сборку или добавлять запись аудита. `POST` можно безопасно повторять, если API поддерживает ключ идемпотентности. Изучайте фактический контракт целевой системы.

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

## Сопоставляйте повторы в ограниченной области намерения

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

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

Хорошее правило сопоставления выглядит так:

```text
if prior.fingerprint == incoming.fingerprint
  and prior.actor == incoming.actor
  and prior.intent_scope == incoming.intent_scope
  and prior.state in {accepted, authorized, dispatched, completed_result_not_delivered}:
    recover_or_attach_to(prior.execution_id)
else:
    create_new_execution()
```

`recover_or_attach_to` не должен бездумно возвращать успех. Поведение зависит от предыдущего состояния.

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

Область действия должна соответствовать операции. Для запроса к API с тайм-аутом может подойти окно в пять минут. Для развертывания, которое длится час, этого недостаточно. Для смены учетных данных может потребоваться долговечный отпечаток, пока не станет понятно, какие учетные данные активны. Не используйте один глобальный TTL только потому, что его легко настроить. Задавайте правила хранения и восстановления для каждого класса действий.

## Согласование служит свидетельством, но не механизмом идемпотентности

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

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

Согласование каждого вызова по-прежнему нужно. Оно управляет авторизацией в момент использования. Но его следует отделять от обработки повторов:

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

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

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

## Ключи идемпотентности HTTP решают только часть проблемы

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

Например, шлюз может создать идентификатор выполнения до отправки и сопоставить его с заголовком, который ожидает API:

```http
POST /v1/releases HTTP/1.1
Host: deploy-api.internal.example
Idempotency-Key: act_01J4K8J7DX7V
Content-Type: application/json

{"service":"catalog","version":"2026.07.22","region":"us-east-1"}
```

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

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

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

## При расследовании изучайте последовательность, а не итоговое число

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

Настоящее расследование должно последовательно ответить на такие вопросы:

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

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

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

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

## Добавьте поведение при повторе в контракт каждого инструмента

Для каждого инструмента с побочным эффектом нужен ясный ответ на один вопрос: что произойдет, если вызывающий потеряет ответ после отправки?

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

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

Именно это должно быть стандартом: неисправный транспорт может прервать разговор, но не должен незаметно превращать неопределенность во второе действие.
