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 провайдера модели. Механика остаётся в спецификации как минимум двенадцать месяцев по политике устаревания, но окно уже открыто.
Причины, по которым от него отказались, видны из самой конструкции:
- Ключи всё равно нужны. Сервер, которому нужна модель, в реальности получает её через клиента — а значит, зависит от того, поддерживает ли конкретный клиент sampling, какую модель выберет и какие лимиты поставит. Это не «независимость от провайдера», а зависимость от клиента.
- Дорого и непредсказуемо для владельца клиента. Пользователь платит за токены, которые израсходовал чужой сервер, и не может оценить объём заранее.
- Контроль качества невозможен. Сервер не знает, какая модель ответит, с каким системным промптом и температурой. Функция, которая «иногда работает», хуже, чем функция без модели.
- Безопасность не сходится. Промпт формирует недоверенная сторона, а исполняет доверенная — классический 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 ₽ контрагенту «Ромашка». Дальше должно произойти вот что.
- Инструмент
prepare_paymentсоздаёт черновик в статусеpendingи возвращаетpayment_handle. Ничего не отправляется в банк. - Инструмент
submit_paymentтребует подтверждения. Если его вызвать без подтверждения, сервер возвращаетinput_requiredс описанием операции: сумма, получатель, назначение, счёт списания. - Клиент показывает человеку ровно эти поля. Не «подтвердить платёж», а полную карточку.
- Человек подтверждает. Клиент повторяет
submit_paymentсinputResponsesи подписаннымrequestState. - Шлюз проверяет: подпись состояния,
subсовпадает с инициатором, срок не истёк, хеш аргументов совпадает с подтверждённым, сумма в пределах лимита роли, получатель не в блок-листе. - Только после этого вызов уходит в банковский адаптер с ключом идемпотентности.
- Адаптер возвращает идентификатор операции. Шлюз перечитывает статус независимо и пишет его в аудит.
Что здесь важно и что обычно забывают:
- Хеш аргументов. Подтверждение привязывается к канонизированным аргументам. Если между подтверждением и исполнением сумму поменяли — хеш не сойдётся, вызов будет отклонён.
- Лимит роли. Подтверждение человеком не отменяет лимит: если у роли нет права на миллион, подтверждение не превращает операцию в разрешённую.
- Проверка результата. «Инструмент вернул 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.