# Вложенные запросы MCP: безопасно отслеживаем каждое внешнее действие

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

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

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

## Важна запись о внешнем действии

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

Разделяйте три понятия, которые команды часто смешивают:

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

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

Присвойте внешнему действию стабильный `action_id`. Свяжите его со span, который его инициировал, но не используйте ID span как идентификатор действия. Один span может охватывать локальную подготовку, попытку HTTP-запроса, перенаправление и разбор ответа. Это полезные детали. Запись действия должна оставаться понятной после изменения реализации.

В записи действия нужно указывать адрес назначения после разрешения. Одних полей `environment=production` или `target=customer-api` недостаточно. Сохраняйте разрешенные хост, порт и протокол, метод запроса или SSH-команду, а также ссылку на учетные данные, выбранные исполнителем. Храните ссылки на секреты, но никогда не сами значения секретов.

Это различие меняет и оценку результата. Обертка может вернуть `ok`, потому что поставила работу в очередь. Нижний уровень затем может завершиться ошибкой, не успев открыть соединение. Записывайте результат для span обертки и результат внешнего действия отдельно. Эти сведения могут расходиться, и при этом обе записи могут быть правильными.

## MCP не предоставляет полный граф вызовов

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

Спецификация Model Context Protocol описывает `tools/call` как запрос клиента к серверу с именем инструмента и аргументами. Это контракт интерфейса, а не контракт трассировки. Протокол не превращает локальный вызов функции в наблюдаемое дочернее событие и не определяет универсальное родительское поле, которое обязан сохранять каждый посредник.

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

Считайте эти связи разными типами ребер в записях:

- `protocol_call` связывает запрос клиента MCP с вызовом инструмента на сервере.
- `local_call` связывает код внутри одного доверенного процесса.
- `delegated_job` связывает запрос с работой, которую позже выполняет другой обработчик.
- `external_action` связывает span с HTTP- или SSH-операцией.

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

Отложенная задача требует особого внимания. Если инструмент ставит задачу в очередь и возвращает ответ, передавайте в данных задачи исходные trace ID и root run ID. Когда обработчик начнет работу, создайте новый span для его выполнения и свяжите его с исходным планом действия. Не делайте вид, что обработчик все время находился внутри исходного запроса. За это время могли измениться длительность выполнения, идентичность и состояние авторизации.

## Создавайте идентификаторы на каждой границе доверия

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

Рекомендация W3C Trace Context определяет заголовок `traceparent` с версией, 32-символьным шестнадцатеричным trace ID, 16-символьным шестнадцатеричным parent ID и флагами. OpenTelemetry широко использует этот формат. Применяйте его там, где HTTP или другой транспорт может передавать заголовки, поскольку существующие инструменты трассировки его понимают. Но совместимость не заменяет модель аудита.

Для MCP через stdio HTTP-заголовка может не быть. Поместите аналогичный контекст в конверт приложения или храните контекст в процессе, который распределяет вызовы. Механизм менее важен, чем два свойства: каждая дочерняя операция должна знать своего непосредственного родителя, а принимающий компонент должен записывать, кто передал ему контекст.

Минимальная форма события может выглядеть так:

```json
{
  "event_id": "evt_01J8...",
  "time": "2025-03-08T14:32:11.214Z",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "parent_span_id": "b7ad6b7169203331",
  "root_run_id": "run_8d43",
  "edge_type": "external_action",
  "actor": {
    "kind": "agent_process",
    "identity": "signed-process-identity"
  },
  "action": {
    "action_id": "act_5f17",
    "channel": "http",
    "method": "POST",
    "host": "deploy.internal.example",
    "path_template": "/v1/releases/{name}",
    "credential_ref": "ops-deploy"
  },
  "outcome": {
    "state": "sent",
    "http_status": 202
  }
}
```

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

Принимающий сервис не должен безоговорочно доверять `parent_span_id`, потому что его мог передать агент. Создайте новый локальный span, сохраните полученное значение и добавьте поле вроде `upstream_context_source=authenticated_mcp_client` или `upstream_context_source=unverified_input`. Это небольшое различие не позволит злоумышленнику задним числом привязать свое действие к безобидному запуску.

## Записывайте результат разрешения, а не только аргументы инструмента

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

Рассмотрим вызов агента с такими аргументами:

```json
{
  "tool": "publish_release",
  "arguments": {
    "environment": "prod",
    "release": "2025.03.08-rc2"
  }
}
```

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

1. Агент вызывает `publish_release` в рамках запуска `run_8d43`.
2. Инструмент преобразует `prod` в конкретный разрешенный endpoint и выбирает ссылку на учетные данные `ops-deploy`.
3. Исполнитель создает запись `act_5f17` непосредственно перед отправкой запроса.
4. Исполнитель записывает ответ или транспортную ошибку для этого действия.
5. Обертка возвращает результат со ссылкой на `act_5f17`, не раскрывая учетные данные.

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

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

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

## Повтор, это новая попытка, а не примечание

Повторы и параллельное выполнение превращают простое дерево в граф. Если поместить все в один span с итоговым полем `success`, исчезнет информация, объясняющая дублирующиеся побочные эффекты и частичный сбой.

Используйте три ID для операции, которая может повторяться: trace ID для всего запуска, action ID для задуманной логической операции и attempt ID для каждой фактической отправки. Для каждой попытки создавайте отдельный span. Запись действия должна ссылаться на все попытки.

Представьте, что инструмент отправляет запрос на публикацию релиза, после принятия запроса удаленным сервисом ждет ответа, получает тайм-аут и повторяет запрос. Если endpoint не поддерживает идемпотентность, второй запрос может дважды создать один и тот же релиз. Итоговый `200` мало что объясняет. Журнал должен показывать, что первая попытка достигла сети и локально завершилась тайм-аутом, а вторая получила ответ.

Используйте токен идемпотентности, если его поддерживает адресат. Получайте его из логического action ID, а не из временного ID span. Тогда удаленный сервис сможет распознать дубликат даже после перезапуска исполнителя или создания библиотекой трассировки новых spans.

```text
trace_id=4bf92f... action_id=act_5f17 attempt=1 state=timeout bytes_sent=418
trace_id=4bf92f... action_id=act_5f17 attempt=2 state=completed http_status=200
```

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

Для параллельной работы нужны дочерние spans, а не общее изменяемое поле трассировки, которое разные обработчики перезаписывают. Если один инструмент планирования вызывает четыре проверки окружения, создайте четыре дочерних span и четыре отдельных результата. Если две ветви приводят к внешним действиям, выдайте два action ID. Оператор должен иметь возможность отозвать или расследовать одну ветвь, не путая ее с соседней.

## Подтверждение должно относиться к разрешенной операции

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

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

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

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

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

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

## Аудитному журналу нужны порядок и проверка

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

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

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

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

```text
$ audit verify journal.events
records_checked: 1842
first_sequence: 91001
last_sequence: 92842
chain: valid
signature: valid
```

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

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

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

## Проверяйте трассировку по реальным сбоям

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

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

- Отправьте вложенный вызов с поддельным родительским ID и убедитесь, что получатель пометил этот контекст как непроверенный.
- Создайте тайм-аут после ухода байтов запроса от клиента, затем повторите запрос и убедитесь, что обе попытки используют один action ID.
- Поставьте работу в очередь, перезапустите обработчик и убедитесь, что возобновленный span связан с исходным запуском, но не выглядит как непрерывное выполнение.
- Отзовите авторизацию после планирования, но до выполнения, и убедитесь, что ни одна запись внешнего действия не получает состояние `sent`.
- Запустите две соседние ветви параллельно и убедитесь, что ни одна не переняла родительский span или результат другой.

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

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

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

## Сначала создайте границу, затем заполняйте граф

Начните с кода, который выполняет HTTP- и SSH-операции. Сделайте так, чтобы он принимал явный контекст трассировки, создавал запись действия до отправки, создавал запись результата после нее и отказывался выполнять операцию, если авторизация не привязана к разрешенной операции.

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

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