Перейти к содержанию

Многораундовые запросы (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 может возвращать результат любого из двух типов:

server.py
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 и читает ответы повторного вызова из контекста:

server.py
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:

client.py
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 отдаёт объединённый тип напрямую:

client.py
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: ключ данных разворачивается один раз при запуске, а криптография для каждого токена остаётся локальной:

server.py
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-стиле; см. Устаревшие возможности.