Многораундовые запросы (multi-round-trip)
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
Иногда инструмент не может завершить работу за один цикл «запрос — ответ». Ему нужно что-то, что есть только у пользователя: выбор, подтверждение, учётные данные.
До версии 2026-07-28 сервер получал это, вызывая клиент в ответ: прямо посреди обработки исходного запроса он открывал собственный запрос к клиенту — элицитацию (elicitation) или вызов сэмплирования (sampling). Спецификация 2026-07-28 упраздняет этот обратный канал (back-channel).
Вместо этого сервер возвращает результат.
Возвращать, а не вызывать в ответ
На tools/call сервер отвечает InputRequiredResult вместо CallToolResult. Всю работу делают два его поля:
input_requests: то, чего серверу ещё не хватает, в виде словаря с ключами, которые выбрал сам сервер. Каждое значение —ElicitRequest,CreateMessageRequestилиListRootsRequest.request_state: непрозрачный токен. При повторном вызове клиент возвращает его дословно. Читает его только ваш сервер.
Клиент выполняет каждый запрос, а затем вызывает тот же инструмент снова, передавая ответы в input_responses, а токен — в request_state. Теперь у сервера есть всё, чего не хватало, и он возвращает обычный CallToolResult.
Вот и весь протокол. Каждый этап — обычный запрос от клиента к серверу. В обратную сторону ничего не передаётся.
Сторона сервера
В @mcp.tool() это редко собирают вручную: объявите зависимость, которая спрашивает пользователя (Elicit), сэмплирует LLM клиента (Sample) или получает список его корневых каталогов (roots; ListRoots), — и SDK вернёт InputRequiredResult за вас; эта форма описана на странице Зависимости. Две формы не сочетаются: у вызова один канал input_responses/request_state, поэтому инструмент с параметрами Resolve(...) не может ещё и возвращать InputRequiredResult из своего тела. Объявленный возвращаемый тип InputRequiredResult отклоняется при регистрации (InvalidSignature), а необъявленный приводит к ошибке вызова во время выполнения. Ручная форма — это низкоуровневый Server, чей обработчик on_call_tool может возвращать результат любого из двух типов:
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
InputRequiredResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
ASK_REGION = ElicitRequest(
params=ElicitRequestFormParams(
message="Which region should the database live in?",
requested_schema={
"type": "object",
"properties": {"region": {"type": "string"}},
"required": ["region"],
},
)
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="provision",
description="Provision a database. Asks which region to put it in.",
input_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult | InputRequiredResult:
answer = (params.input_responses or {}).get("region")
if not isinstance(answer, ElicitResult) or answer.content is None:
return InputRequiredResult(input_requests={"region": ASK_REGION}, request_state="provision-v1")
name = (params.arguments or {})["name"]
text = f"Provisioned {name!r} in {answer.content['region']}."
return CallToolResult(content=[TextContent(type="text", text=text)])
server = Server("Provisioner", on_list_tools=list_tools, on_call_tool=call_tool)
on_call_toolимеет тип-> CallToolResult | InputRequiredResult. Вернуть второй — вот и весь серверный API.- При первом вызове
params.input_responsesравенNone, поэтому срабатывает проверка и обработчик спрашивает, а не отвечает. - При повторном вызове
ElicitResult, присланный клиентом, лежит под тем же ключом ("region"), который сервер использовал вinput_requests.
Всё остальное в этом файле (явная input_schema, собранный вручную CallToolResult) — обычный низкоуровневый Server, описанный на странице Низкоуровневый Server. Эта страница лишь добавляет второй тип возвращаемого значения.
Не только инструменты
tools/call ничем не выделяется: в версии 2026-07-28 сервер может так же отвечать на prompts/get и resources/read. В MCPServer функция @mcp.prompt() — или шаблонная функция @mcp.resource() — сама возвращает InputRequiredResult и читает ответы повторного вызова из контекста:
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
mcp = MCPServer("Briefing")
ASK_AUDIENCE = ElicitRequest(
params=ElicitRequestFormParams(
message="Who is the briefing for?",
requested_schema={
"type": "object",
"properties": {"audience": {"type": "string"}},
"required": ["audience"],
},
)
)
@mcp.prompt()
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
"""Draft a briefing tuned to its audience."""
answer = (ctx.input_responses or {}).get("audience")
if not isinstance(answer, ElicitResult) or answer.content is None:
return InputRequiredResult(input_requests={"audience": ASK_AUDIENCE})
return [UserMessage(f"Write a briefing for {answer.content['audience']}.")]
- Первый раунд возвращает
InputRequiredResult. При повторном вызове ответы лежат вctx.input_responsesпод теми же ключами, и функция возвращает свой обычный результат — здесь это сообщения промпта, а для шаблонного ресурса — содержимое ресурса. - Заданный вами
request_stateзапечатывается перед отправкой по сети и проверяется при возврате, как и всё остальное на сервере; раздел ЗащитаrequestStateниже рассказывает, что даёт запечатывание и когда нужно настраивать ключи. - Функция
@mcp.tool()может точно так же вернуть результат напрямую, если форма с зависимостями не подходит. - Статические функции
@mcp.resource()не участвуют: они не принимаютContext, а значит, никак не смогли бы прочитать повторный вызов. Спрашивать могут только шаблонные ресурсы. - Правила поколений, описанные ниже, действуют без изменений: вернуть
InputRequiredResultв сессии до 2026 года — это та же ошибка-32603, о которой говорит предупреждение.
Сторона клиента
Client выполняет цикл за вас.
Зарегистрируйте колбэки, которые могут понадобиться серверу (elicitation_callback, sampling_callback, list_roots_callback), и вызовите инструмент. Когда приходит InputRequiredResult, Client передаёт каждую запись из input_requests соответствующему колбэку, повторяет вызов с ответами и возвращённым request_state и продолжает, пока не вернётся CallToolResult:
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult
async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"region": "eu-west-1"})
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=handle_elicitation) as client:
result = await client.call_tool("provision", {"name": "orders"})
print(result.content)
- Этот
elicitation_callback— тот же самый, в который попал быelicitation/createпо обратному каналу от сервера до 2026 года. То же верно дляsampling_callbackиsampling/createMessage, а также дляlist_roots_callbackиroots/list: в версии 2026-07-28 отдельных RPC от сервера к клиенту больше нет, но те же самые полезные нагрузкиElicitRequest/CreateMessageRequest/ListRootsRequestпередаются внутриinput_requestsи попадают в те же три колбэка. Один набор колбэков обслуживает оба поколения. call_toolвозвращает обычныйCallToolResult. Промежуточные раунды для вызывающего кода невидимы.get_promptиread_resourceзапускают тот же цикл.
Check
Уберите колбэк — и цикл завершится ошибкой на первом же раунде: колбэк-заглушка SDK
отвечает на каждую элицитацию ошибкой, и call_tool выбрасывает MCPError с сообщением
«Elicitation not supported».
Цикл ограничен. По умолчанию предел — Client(..., input_required_max_rounds=10); если сервер продолжает возвращать InputRequiredResult сверх него, call_tool выбрасывает исключение. Если раунд несёт только request_state без input_requests, Client перед повтором делает короткую паузу (50 мс, с удвоением до потолка в 250 мс), чтобы не донимать частым опросом сервер, который просто говорит «ещё не готово».
Управление циклом вручную
Автоматического цикла хватает для клиента в одном процессе. Берите цикл в свои руки, когда:
- Клиент распределённый: процесс, который показывает вопрос пользователю, — не тот, что вызвал
call_tool, поэтому повторный вызов отправляет другой рабочий процесс.request_state— сохраняемый токен, который переносится через эту границу с помощью вашего собственного хранилища, аinput_responses— то, что другая сторона отправляет вместе с ним. - Нужно инспектировать каждый раунд: логировать или аудировать каждую запись
input_requests, отклонять определённые виды запросов или применять собственную задержку между этапами. - Нужно ограничение по реальному времени, а не по числу раундов: оберните собственный цикл в
anyio.fail_after(...), вместо того чтобы полагаться наinput_required_max_rounds.
Спуститесь к нижележащей сессии, где allow_input_required=True отдаёт объединённый тип напрямую:
from mcp import Client
from mcp.types import CallToolResult, ElicitRequest, ElicitResult, InputRequest, InputRequiredResult, InputResponse
def fulfil(request: InputRequest) -> InputResponse:
if not isinstance(request, ElicitRequest):
raise NotImplementedError(f"this client cannot answer a {request.method!r} request")
return ElicitResult(action="accept", content={"region": "eu-west-1"})
async def provision(client: Client, name: str) -> CallToolResult:
result = await client.session.call_tool("provision", {"name": name}, allow_input_required=True)
while isinstance(result, InputRequiredResult):
responses = {key: fulfil(request) for key, request in (result.input_requests or {}).items()}
result = await client.session.call_tool(
"provision",
{"name": name},
input_responses=responses,
request_state=result.request_state,
allow_input_required=True,
)
return result
client.session.call_tool(..., allow_input_required=True)расширяет возвращаемый тип доCallToolResult | InputRequiredResult. Обратно его сужаетisinstance.request_stateтеперь в ваших руках. Записывайте его между этапами — и разговор можно продолжить из нового процесса.- Для каждой записи в
input_requestsкладитеInputResponseпод тем же ключом вinput_responses.fulfil— место для вашего UI; здесь ответ зашит в коде. - То же имя инструмента, те же
arguments— на каждом этапе. Повторный вызов — это исходный вызов, выполненный ещё раз, а не новый метод.
Защита requestState
Всё сказанное выше обращается с request_state как с эхом, и в передаваемых данных это эхо и есть. Но между этапами его держит клиент (записывать его при переходе между процессами — ровно то, что одобрил предыдущий раздел), поэтому возвращается ввод, предоставленный клиентом: его могли изменить, он мог устареть или его могли целиком взять из другого вызова. Спецификация требует, чтобы серверы защищали целостность этого состояния и отклоняли раунд при неудачной проверке — всякий раз, когда состояние способно повлиять на авторизацию, доступ к ресурсам или бизнес-логику.
MCPServer защищает его по умолчанию. Каждый сервер запечатывает исходящий requestState и проверяет каждое эхо — и состояние резолверов, и собранное вручную — ключом, сгенерированным при запуске процесса. Настраивать ничего не нужно: вы пишете открытый текст и читаете открытый текст, а по сети всегда передаётся только непрозрачный зашифрованный токен.
Ключ по умолчанию живёт и умирает вместе с процессом — и это единственное, что нужно знать перед развёртыванием за пределами одного процесса:
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
- Вариант по умолчанию (без настройки) подходит для одного процесса: stdio или ровно один рабочий процесс HTTP. Повторный вызов, попавший на другой рабочий процесс, на другой экземпляр за балансировщиком нагрузки или на тот же сервер после перезапуска, запечатан ключом, которого у этого процесса нет, — клиент получает описанный ниже фиксированный отказ и должен начать обмен заново.
keys=[...]обязателен всякий раз, когда повторный вызов может попасть на другой экземпляр (uvicornс несколькими рабочими процессами, HTTP за балансировщиком) или должен переживать перезапуски: каждый экземпляр проверяет то, что выпустил любой из его собратьев. Тот же механизм, только ваш секрет вместо сгенерированного.- Для собственной криптографии, например KMS или уже существующего сервиса токенов, передайте
RequestStateSecurity(codec=...)вместоkeys; контракт описан ниже, в разделе Собственная криптография.
Что несёт в себе запечатанный токен
С настройками или без, requestState в передаваемых данных — это зашифрованный аутентифицированный токен. Ваш код его никогда не видит: обработчики и резолверы пишут и читают открытый текст (ctx.request_state); SDK запечатывает на выходе и проверяет на входе. Помимо целостности, каждый токен привязан к:
- Временному окну. Каждый раунд запечатывает состояние заново со свежим сроком действия, поэтому
RequestStateSecurity(ttl=...)(по умолчанию 600 секунд) ограничивает время на раздумья в одном раунде, а не весь обмен. - Аутентифицированному принципалу. Когда запрос несёт OAuth-токен доступа, проверенный SDK, состояние привязывается к клиенту, издателю и субъекту токена: состояние, выпущенное для одного пользователя, не пройдёт проверку у другого, даже если оба пользуются одним OAuth-клиентом. Верификатор, не сообщающий субъекта, ослабляет привязку до одной лишь идентичности клиента, которая при идентификаторах клиентов на основе URL общая для всех пользователей этого клиентского ПО. Когда аутентификация завершается вне SDK (на прокси перед сервером) или транспорт не аутентифицирован, привязывать не к кому, и эта проверка бездействует — если только
RequestStateSecurity(bind_principal=...)не предоставит принципала из вашего собственного сигнала идентичности. Какие бы компоненты ни сообщал ваш верификатор токенов, он должен сообщать их единообразно: верификатор, который в одних запросах включает субъект, а в других опускает, меняет принципала посреди обмена, и раунды в процессе выполнения отклоняются. - Исходному запросу. Метод, имя инструмента или промпта (или URI ресурса) и дайджест аргументов. Токен, воспроизведённый против другого инструмента, других аргументов или другого метода, не пройдёт проверку.
- Точному заданному вопросу. Каждый ответ резолвера закреплён за отрисованным вопросом, который показали клиенту, — и в том раунде, где он впервые приходит, и когда записанный ответ используется повторно позже. Разверните версию с перефразированным сообщением или изменённой схемой — и сервер спросит заново, а не примет устаревший ответ. Это же закрепление работает и в другую сторону: стройте сообщения из аргументов инструмента, а не из данных конкретного вызова. Сообщение, собранное из метки времени или текущего курса, в каждом раунде отрисовывается по-разному, поэтому каждый записанный ответ выглядит устаревшим, и сервер переспрашивает, пока лимит раундов клиента не завершит вызов.
Всё это — работа SDK, а не ваша, и не работа кодека, если вы приносите свой.
Ротация ключей
keys[0] запечатывает новое состояние; проверяет каждый ключ из списка. Ротация без простоя проходит в три фазы, каждая из которых полностью развёрнута до начала следующей:
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
Никогда не продвигайте выпускающий ключ первым: выпуск под ключом, который какой-то экземпляр ещё не умеет проверять, обрывает раунды в процессе выполнения прямо посреди развёртывания.
Ключи ограничены одним сервисом. Запечатанный конверт также содержит имя сервера в качестве утверждения об аудитории (audience), поэтому токен, выпущенный другим сервисом, который случайно использует тот же секрет, всё равно отклоняется. Это утверждение отличительно ровно настолько, насколько отличительно имя, поэтому сервер с явно заданной политикой должен иметь настоящее имя или задать RequestStateSecurity(audience=...) — безымянный выбрасывает исключение при создании. audience= также служит намеренным многосервисным топологиям, где один сервис должен принимать состояние, выпущенное другим. (Вариант по умолчанию без настройки от этого освобождён: его ключ никогда не покидает процесс, так что утверждению об аудитории нечего добавить.)
Собственная криптография
RequestStateSecurity(codec=...) принимает что угодно с методами seal(bytes) -> str и unseal(str) -> bytes, выбрасывающими InvalidRequestState для любого токена, который этот кодек не выпускал. Классическая форма — конвертное шифрование через KMS: ключ данных разворачивается один раз при запуске, а криптография для каждого токена остаётся локальной:
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from mcp.server import MCPServer
from mcp.server.mcpserver import InvalidRequestState, RequestStateSecurity
PREFIX = "kms1." # format version; fed to GCM as associated data, so it is bound under the tag
def unwrap_data_key() -> bytes:
"""One KMS call at process start, kms.decrypt(CiphertextBlob=...); every token after that is local crypto."""
return os.urandom(32) # stand-in for the unwrapped 32-byte data key
class EnvelopeCodec:
def __init__(self, data_key: bytes) -> None:
self._aesgcm = AESGCM(data_key)
def seal(self, payload: bytes) -> str:
nonce = os.urandom(12)
return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex()
def unseal(self, token: str) -> bytes:
if not token.startswith(PREFIX):
raise InvalidRequestState("unknown token format")
body = token[len(PREFIX) :]
try:
raw = bytes.fromhex(body)
if raw.hex() != body: # only the exact string seal() produced verifies
raise ValueError("non-canonical hex")
return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode())
except (ValueError, InvalidTag) as exc:
raise InvalidRequestState("token failed verification") from exc
mcp = MCPServer("Deployer", request_state_security=RequestStateSecurity(codec=EnvelopeCodec(unwrap_data_key())))
TTL, привязка к принципалу и привязка к запросу — не забота кодека: SDK вписывает их в полезную нагрузку перед seal и заново проверяет после unseal, для любого кодека. Единственные обязанности кодека — целостность (подделан — значит, исключение) и, в идеале, конфиденциальность.
Когда проверка не проходит
Любой входящий сбой — токен подделан, просрочен, воспроизведён против другого запроса или принципала либо запечатан ключом, которого этот сервер не знает, — получает один и тот же ответ:
{"code": -32602, "message": "Invalid or expired requestState"}
Одно фиксированное сообщение на все причины, так что по сети никогда не раскрывается, какая именно проверка не прошла; настоящая причина уходит в лог сервера. Проверяется каждый входящий requestState в tools/call, prompts/get и resources/read, в том числе пришедший для обработчика, который вообще не выпускает состояние. На практике самый частый отказ — это не атака, а локальный для процесса ключ по умолчанию, встретивший повторный вызов, сделанный до перезапуска или с другого экземпляра; клиент начинает обмен заново, а когда это важно, лекарство — keys=[...].
Состояние, собранное вручную
request_state, который вы задаёте сами (возвращая InputRequiredResult из функции инструмента, промпта или шаблонного ресурса), запечатывается и проверяется тем же механизмом, что и состояние резолверов, без единого изменения в коде: пишете открытый текст, читаете открытый текст, и действуют все описанные выше привязки.
Единственное, что SDK не может закрепить за вас, даже будучи настроенным, — это идентичность вопроса: он не знает, к какому из ваших вопросов относится ответ в вашем состоянии. Если ответы хранятся с ключами по вопросам, включайте в состояние собственный идентификатор вопроса и проверяйте его при повторном вызове.
Низкоуровневый Server — уровень без готовых решений: в отличие от MCPServer, ничего не запечатывается, пока вы сами не добавите эту границу, и до тех пор ваш request_state передаётся по сети ровно так, как записан. Однострочное включение показано на странице Низкоуровневый Server.
Результат версии 2026-07-28
InputRequiredResult существует только в версии протокола 2026-07-28. Client(server) в памяти согласует её за вас; по сети её обнаруживает mode="auto". После подключения client.protocol_version сообщает, что именно получилось.
Warning
В сессии до 2026 года InputRequiredResult некуда положить. Верните его из обработчика на
подключении mode="legacy" — и исполнитель не сможет сериализовать его в согласованную версию;
клиент получит ошибку -32603 «Handler returned an invalid result». Сервер, обслуживающий
оба поколения, должен проверять ctx.protocol_version, прежде чем к нему прибегать.
Info
Элицитация в режиме URL на подключении 2026 года работает ровно на этом механизме. Запись в
input_requests — это ElicitRequest, чьи параметры — ElicitRequestURLParams; пользователь
завершает внешний сценарий, и ваш клиент повторяет вызов. Тот же цикл, никакого нового API.
Часть про высокоуровневый сервер — на странице Элицитация.
Итоги
- В версии 2026-07-28 сервер, которому нужен ввод посреди вызова, возвращает
InputRequiredResult. Он никогда не открывает запрос к клиенту. input_requests— то, что ему нужно.request_state— непрозрачный токен возобновления, который читает только сервер.Clientвыполняет цикл повторов за вас: зарегистрируйтеelicitation_callback/sampling_callback/list_roots_callback— иcall_toolвернёт обычныйCallToolResult. Ограничивает егоinput_required_max_rounds(по умолчанию 10).- Чтобы инспектировать или сохранять раунды, используйте
client.session.call_tool(..., allow_input_required=True)и ведите циклwhile isinstance(result, InputRequiredResult)сами. - В
@mcp.tool()этот результат за вас формирует зависимость, которая спрашивает пользователя (Зависимости); низкоуровневыйServer— ручная форма. - Промпты и ресурсы тоже участвуют: функция
@mcp.prompt()или шаблонная@mcp.resource()сама возвращаетInputRequiredResultи читаетctx.input_responsesпри повторном вызове. requestStateвозвращается как ввод, предоставленный клиентом, поэтомуMCPServerзапечатывает его по умолчанию — и состояние резолверов, и собранное вручную — локальным для процесса ключом; развёртывания с несколькими экземплярами передаютRequestStateSecurity(keys=[...])(или собственный кодек), чтобы каждый экземпляр мог проверить то, что выпустил его собрат. Запечатывание привязывает каждый токен к временному окну, исходному запросу и аутентифицированному принципалу — когда запрос несёт аутентификацию, проверенную SDK, илиbind_principal=предоставляет ваш собственный сигнал идентичности (ЗащитаrequestState).
Это механизм, который заменяет сэмплирование по инициативе сервера и весь остальной обратный канал в push-стиле; см. Устаревшие возможности.