Назад в блог

Sampling и elicitation в MCP: кто платит за токены и кто подтверждает действие

Sampling в MCP устарел, а elicitation остался единственным законным способом спросить человека. Разбираем обе механики, атаки и политику на шлюзе.

Иллюстрация к материалу: Sampling и elicitation в MCP: кто платит за токены и кто подтверждает действие

MCP-сервер умеет не только отвечать на вызовы инструментов: в протоколе есть два механизма, где инициатива идёт в обратную сторону. Первый — sampling: сервер просит клиента сгенерировать текст моделью. Второй — elicitation: сервер просит клиента спросить у человека недостающие данные или подтверждение.

Оба механизма выглядят как удобная мелочь, но именно они превращают «набор функций» в диалог с пользователем. А любой диалог — это канал, через который можно выманить секрет, подделать согласие или заставить человека подтвердить не то, что он думает. Плюс есть третья, самая практичная проблема: за токены платит тот, чья модель работает, и в схеме со sampling это не всегда очевидно.

Ниже — как обе механики устроены в ревизии MCP 2026-07-28, почему sampling попал в устаревшие, как выглядит запрос ввода после перехода на Multi Round-Trip Requests, где ломается доверие и какие правила стоит поставить на шлюзе до того, как включать это в продакшене.

Главное за минуту

  • Sampling устарел в ревизии 2026-07-28 (SEP-2577). Он остаётся в спецификации минимум двенадцать месяцев, но новые реализации не должны его внедрять: серверу предлагают ходить в API провайдера модели напрямую.
  • Elicitation остался и стал строже: два режима — форма и URL. Секреты, ключи и платёжные данные через форму запрашивать запрещено.
  • Серверные запросы больше не живут отдельно. По SEP-2322 сервер возвращает результат «нужен ввод» (resultType: "input_required"), клиент повторяет вызов с ответами. Состояние едет внутри запроса, а не в соединении.
  • requestState — это подписанный конверт, а не base64. Если его можно подменить, пользователь подтвердит одно, а выполнится другое.
  • Подтверждение — это авторизация. Оно должно быть привязано к конкретному вызову, аргументам и человеку, иначе повторное использование согласия становится дырой.

Что такое sampling и откуда он взялся

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

Для вендора MCP-сервера это было привлекательно: не нужно хранить ключи, платить за инференс и объяснять клиенту, куда уходят данные. Клиент остаётся единственной точкой, где модель вообще существует, а сервер получает «умную» функцию без своей инфраструктуры.

Типичные сценарии выглядели так:

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

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

С точки зрения безопасности sampling — это канал, по которому сторонний сервер может:

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

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

Почему sampling устарел

В ревизии 2026-07-28 sampling помечен как deprecated (SEP-2577). Формулировка прямолинейная: новые реализации не должны его внедрять, существующим стоит мигрировать на прямую интеграцию с API провайдера модели. Механика остаётся в спецификации как минимум двенадцать месяцев по политике устаревания, но окно уже открыто.

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

  1. Ключи всё равно нужны. Сервер, которому нужна модель, в реальности получает её через клиента — а значит, зависит от того, поддерживает ли конкретный клиент sampling, какую модель выберет и какие лимиты поставит. Это не «независимость от провайдера», а зависимость от клиента.
  2. Дорого и непредсказуемо для владельца клиента. Пользователь платит за токены, которые израсходовал чужой сервер, и не может оценить объём заранее.
  3. Контроль качества невозможен. Сервер не знает, какая модель ответит, с каким системным промптом и температурой. Функция, которая «иногда работает», хуже, чем функция без модели.
  4. Безопасность не сходится. Промпт формирует недоверенная сторона, а исполняет доверенная — классический confused deputy, только на уровне контекста.

Практический вывод для команды, которая поддерживает MCP-серверы: если у вас есть sampling, планируйте его удаление или перевод на собственный вызов модели. Если вы только проектируете сервер — не начинайте. Собственный вызов модели означает, что вы отвечаете за ключи, бюджет, выбор модели и обработку данных, зато перестаёте зависеть от возможностей клиента.

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

Elicitation: единственный законный способ спросить человека

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

В спецификации 2026-07-28 есть два режима.

Form mode — сервер присылает сообщение и, опционально, JSON-схему ответа. Клиент рисует форму, пользователь заполняет, клиент валидирует ответ по схеме и отправляет назад. Подходит для выбора из списка, подтверждения boolean, ввода нечувствительных параметров.

URL mode — сервер отдаёт ссылку, по которой пользователь должен перейти в браузере. Режим появился в ревизии 2025-11-25 именно потому, что форма не подходит для чувствительных взаимодействий: OAuth-флоу, оплата, ввод учётных данных. Смысл в том, что данные не проходят через MCP-клиент и не попадают в контекст модели.

Ключевое нормативное ограничение: сервер не должен запрашивать через форму пароли, API-ключи, токены доступа и платёжные данные. Для всего этого есть URL mode. Разделение важное, потому что форма живёт внутри агентского цикла: её содержимое оказывается в контексте модели, в логах клиента и в трейсах.

Клиент, в свою очередь, обязан:

  • явно показывать, какой сервер просит данные;
  • давать отказ и отмену в любой момент;
  • для формы — показывать ответ до отправки и разрешать правку;
  • для URL — показывать полный адрес и домен, спрашивать согласие перед переходом.

Как это выглядит после Multi Round-Trip Requests

До ревизии 2026-07-28 сервер посреди обработки вызова отправлял клиенту отдельный JSON-RPC запрос elicitation/create и ждал ответа на том же соединении. Для этого нужен был двусторонний поток и, значит, состояние.

Теперь серверных запросов «в пустоту» нет. По SEP-2322 результат вызова несёт признак типа: complete или input_required. Во втором случае сервер возвращает, что именно ему нужно, и непрозрачный requestState — где он остановился. Клиент собирает ответы и повторяет тот же вызов с ответами и прежним состоянием.

{
  "resultType": "input_required",
  "inputRequests": {
    "branch": {
      "type": "elicitation",
      "message": "В какой филиал провести документ?",
      "schema": {
        "type": "object",
        "properties": {
          "branch_id": { "type": "string", "enum": ["msk", "spb", "ekb"] }
        },
        "required": ["branch_id"],
        "additionalProperties": false
      }
    },
    "confirm": {
      "type": "elicitation",
      "message": "Провести документ №4581 на 1 240 000 ₽? Это необратимо.",
      "schema": { "type": "boolean" }
    }
  },
  "requestState": "eyJ2IjoxLCJjYWxsIjoiZDQxYyIsImRvYyI6IjQ1ODEifQ.hmac"
}

Клиент показывает два вопроса, получает ответы, проверяет их по схеме и повторяет исходный tools/call, добавив inputResponses и тот же requestState.

Что это меняет для безопасности:

  • Каждый запрос ввода виден в журнале как отдельный шаг. Шлюз может записать: кто спросил, что спросил, что ответили.
  • Состояние можно проверить. Оно не обязано быть непрозрачным для вас: сервер может подписать его и проверить при повторном вызове.
  • Появляется соблазн подделки. requestState передаётся через клиента. Если это просто base64 с параметрами операции, клиент или посредник может заменить сумму, получателя или список файлов — и сервер выполнит не то, на что согласился человек.

Спецификация описывает requestState как непрозрачный токен и оставляет защиту целостности реализации. Это значит, что подпись — ваша задача, а не протокола.

requestState: подпись, привязка, срок жизни

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

import hmac, hashlib, json, time, base64

def seal(state: dict, key: bytes) -> str:
    body = json.dumps(state, sort_keys=True, separators=(",", ":")).encode()
    tag = hmac.new(key, body, hashlib.sha256).digest()
    return base64.urlsafe_b64encode(body + b"." + tag).decode()

def unseal(token: str, key: bytes, principal: str) -> dict:
    raw = base64.urlsafe_b64decode(token)
    body, tag = raw.rsplit(b".", 1)
    expected = hmac.new(key, body, hashlib.sha256).digest()
    if not hmac.compare_digest(tag, expected):
        raise ValueError("state_tampered")
    state = json.loads(body)
    if state["sub"] != principal:
        raise ValueError("state_wrong_user")
    if state["exp"] < int(time.time()):
        raise ValueError("state_expired")
    return state

Четыре свойства, без которых конструкция не работает:

  • Канонизация. Подписывать нужно нормализованный JSON (sort_keys, фиксированные разделители), иначе две одинаковые по смыслу структуры дадут разные подписи, и вы начнёте отлаживать несуществующие ошибки.
  • Привязка к субъекту. В состоянии лежит sub — идентификатор пользователя из проверенной аутентификации, а не из аргументов вызова. Чужой requestState, переданный в чужом вызове, отвергается.
  • Срок жизни. Пятнадцать минут — разумная граница для человеческого ответа. Час — уже слишком много: за час успевают измениться права.
  • Одноразовость для необратимых операций. Если состояние закрывает платёж или удаление, храните использованные идентификаторы (в Redis с TTL) и отвергайте повтор.

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

Пять мест, где ломается доверие

Разберём типовые ошибки не абстрактно, а по слоям.

1. Сервер просит больше, чем нужно операции. Инструмент create_invoice просит «подтвердите создание счёта», а на самом деле в том же цикле закрывает период. Пользователь подтверждает одно, выполняется другое. Лечится правилом: текст вопроса генерируется из того же описания операции, что и выполняется, и не может быть расширен кодом инструмента.

2. Клиент показывает сокращённый текст. Если интерфейс рисует «Подтвердить?» без суммы и получателя, согласие бессмысленно. OWASP в чек-листе по MCP прямо требует показывать полные параметры вызова, а не только имя инструмента.

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

4. Усталость от подтверждений. Если агент спрашивает двадцать раз за задачу, человек начинает жать «да» не читая. Это не проблема пользователя, а проблема дизайна: частые подтверждения нужно агрегировать в один шаг («провести три документа: список, сумма, период»), а не размазывать по циклу.

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

К этому добавляется атака из спецификации, которая касается URL mode. Сервер формирует ссылку на авторизацию у третьей стороны. Злоумышленник не переходит по ней сам, а уговаривает другого пользователя того же сервера перейти и завершить авторизацию. Сервер получает токены и привязывает их к инициатору — то есть к атакующему. Защита: сервер обязан убедиться, что человек, открывший ссылку, — тот же, кто инициировал запрос. На практике это делается через промежуточный адрес самого сервера, который сверяет сессионную cookie с sub из MCP-авторизации, и только потом редиректит к стороннему провайдеру.

Elicitation и секреты: почему форма — не место для пароля

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

Форма живёт в контексте агента. Всё, что пользователь ввёл, может:

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

Если инструменту нужен доступ к системе, правильный ответ — не спрашивать пароль, а получить короткоживущий токен через OAuth или выдать его на стороне шлюза. Codenik, например, подставляет секреты в исходящий вызов на своей стороне: агент не видит ни пароля, ни токена, а в контексте остаётся только имя инструмента и его аргументы.

Для URL mode правило другое: секрет вводится на стороне сервера-владельца, в браузере, вне контекста модели. Но и здесь есть требования: ссылка не должна содержать персональных данных, не должна быть предварительно аутентифицирована (иначе её можно использовать для имперсонации), а клиент не должен открывать её без явного согласия и не должен предзагружать её содержимое.

Что решать на шлюзе

Если у вас один MCP-сервер и один клиент, часть проблем решается дисциплиной. Как только серверов десяток, а клиентов несколько, нужна одна точка, где принимается решение. Шлюз видит и запрос, и ответ, и запрос ввода — значит, именно он может применить политику.

Разумный набор правил:

СобытиеРешение по умолчанию
input_required с формойразрешить, если в схеме нет полей класса «секрет»
input_required с URLразрешить, если домен в allowlist и это HTTPS
запрос подтверждения на write/deleteтребовать, чтобы вызов шёл через approval-очередь
повторный requestStateотвергать для необратимых операций
изменение набора инструментов после согласияпереспросить, инвалидировать кэш согласий
вызов инструмента, которого не было в подтверждённом планеdeny

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

Технически это выглядит как проверка перед каждым вызовом: шлюз получает нормализованный запрос (кто, какой инструмент, какие аргументы, в рамках какого плана), сверяет с выданным согласием и только потом пропускает. Всё, что не совпало, — deny, а не «спросим ещё раз на всякий случай». Повторный вопрос после расхождения маскирует атаку.

Рабочий сценарий: согласование платежа

Возьмём платёж — самый неудобный случай, потому что ошибка необратима.

Агент готовит платёжное поручение на 1 240 000 ₽ контрагенту «Ромашка». Дальше должно произойти вот что.

  1. Инструмент prepare_payment создаёт черновик в статусе pending и возвращает payment_handle. Ничего не отправляется в банк.
  2. Инструмент submit_payment требует подтверждения. Если его вызвать без подтверждения, сервер возвращает input_required с описанием операции: сумма, получатель, назначение, счёт списания.
  3. Клиент показывает человеку ровно эти поля. Не «подтвердить платёж», а полную карточку.
  4. Человек подтверждает. Клиент повторяет submit_payment с inputResponses и подписанным requestState.
  5. Шлюз проверяет: подпись состояния, sub совпадает с инициатором, срок не истёк, хеш аргументов совпадает с подтверждённым, сумма в пределах лимита роли, получатель не в блок-листе.
  6. Только после этого вызов уходит в банковский адаптер с ключом идемпотентности.
  7. Адаптер возвращает идентификатор операции. Шлюз перечитывает статус независимо и пишет его в аудит.

Что здесь важно и что обычно забывают:

  • Хеш аргументов. Подтверждение привязывается к канонизированным аргументам. Если между подтверждением и исполнением сумму поменяли — хеш не сойдётся, вызов будет отклонён.
  • Лимит роли. Подтверждение человеком не отменяет лимит: если у роли нет права на миллион, подтверждение не превращает операцию в разрешённую.
  • Проверка результата. «Инструмент вернул ok» — не доказательство. Нужно независимое чтение статуса, иначе повтор после таймаута создаст второй платёж.
  • Идемпотентность. Ключ строится из идентификатора черновика, а не из случайного UUID на каждый вызов, — иначе retry превращается в дубль.

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

Аудит: что писать в журнал

Запросы ввода — это события авторизации, и в журнале они должны выглядеть соответственно. Минимальный набор полей на каждый цикл:

{
  "request_id": "req_01J9...",
  "principal": "user:anna@example.com",
  "agent": "agent:sales-assistant",
  "tool": "submit_payment",
  "args_hash": "sha256:8f3c...",
  "input_requested": ["confirm"],
  "input_source": "form",
  "decision": "allow",
  "policy": "finance/payments@v14",
  "approval_id": "APR-2026-000184",
  "result": "accepted",
  "duration_ms": 1840
}

Чего в журнале быть не должно: содержимого ответов формы, если там могут быть персональные данные; секретов; полного текста промпта. Пишите, что спросили и какое решение приняли, — не сами значения.

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

Чеклист перед включением

  • В сервере нет sampling; если есть — есть план миграции и понятно, чья модель и чей бюджет.
  • Формы не запрашивают секреты, ключи, токены, платёжные данные.
  • URL mode разрешён только для HTTPS-доменов из allowlist.
  • requestState подписан, привязан к sub и вызову, имеет TTL.
  • Необратимые операции отвергают повторное использование состояния.
  • Подтверждение привязано к хешу аргументов, а не к имени инструмента.
  • Клиент показывает полные параметры, а не сокращённый текст.
  • Есть лимит на количество подтверждений в одной задаче и способ их агрегировать.
  • Отказы и таймауты попадают в аудит.
  • Для чувствительных операций есть независимая проверка результата.

FAQ

Sampling ещё можно использовать? Технически да: он остаётся в спецификации минимум двенадцать месяцев после 2026-07-28. Но новые реализации внедрять не стоит, а существующие лучше переводить на прямой вызов модели — иначе вы зависите от того, поддерживает ли конкретный клиент механику.

Чем elicitation отличается от обычного вопроса в чате? Тем, что вопрос задаёт сервер в рамках конкретной операции, а ответ уходит обратно в эту операцию структурированными данными по схеме. Чат — свободный текст, который модель может интерпретировать как угодно.

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

Нужен ли elicitation, если у нас всё read-only? Обычно нет. Он нужен там, где операция необратима или требует выбора, который нельзя вывести из контекста. Для чтения достаточно ошибки валидации.

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

Где Codenik

Codenik стоит между агентом и MCP-инструментами и видит то, что теряется в связке «клиент — сервер»: кто инициировал вызов, какие аргументы ушли, было ли подтверждение и чем оно закончилось. Запросы ввода проходят через ту же точку: формы проверяются на отсутствие секретных полей, URL — на домен из allowlist, а подтверждения привязываются к вызову и попадают в аудит вместе с решением политики.

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

Источники

  • Model Context Protocol, спецификация 2026-07-28 — раздел Elicitation: режимы, запрет на секреты в форме, защита от фишинга.
  • Model Context Protocol, Sampling (draft): пометка deprecated по SEP-2577 и окно устаревания.
  • SEP-2322, Multi Round-Trip Requests: resultType, inputRequests, requestState.
  • SEP-2260, Require Server requests to be associated with a Client request: привязка запросов ввода к исходному вызову и таймауты.
  • OWASP, MCP Security Cheat Sheet: human-in-the-loop, показ полных параметров, запрет автоподтверждения.
  • NSA, «Model Context Protocol (MCP): Security Design Considerations»: неоднозначность контроля доступа в MCP-реализациях.
  • Model Context Protocol, ревизия 2025-11-25: появление URL mode в elicitation.