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

Элицитация

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

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

Элицитация (elicitation) позволяет ему спросить. Прямо посреди вызова инструмента пользователь получает вопрос, а его ответ возвращается в тот же самый вызов функции.

Есть два режима:

  • Режим формы: нужно значение (подтверждение, дата, количество). Вы описываете поля, клиент отображает форму.
  • Режим URL: нужно, чтобы пользователь перешёл куда-то ещё (экран согласия OAuth, страница оплаты). Ничто из того, что он там делает, не проходит через протокол.

И есть два способа спросить. Предпочтительный — резолвер: вопрос привязывается к параметру, а SDK задаёт его сам — на любом подключении, какого бы поколения протокол ни использовал клиент. Прямой способ, await ctx.elicit(...), — это запрос от сервера к клиенту, а такой канал существует только для клиента на подключении старого поколения (версия спецификации 2025-11-25 или более ранняя). На этой странице описаны оба; начните с резолвера.

Вопрос с помощью резолвера

Вопрос, от которого зависит весь инструмент, — вы уверены? какой из трёх подходящих аккаунтов? — можно вынести из тела инструмента в резолвер, и фреймворк задаст его за вас.

Параметр с аннотацией Annotated[T, Resolve(fn)] заполняется результатом вызова fn перед телом инструмента. Резолвер возвращает значение напрямую, если уже знает его, или возвращает Elicit(...), чтобы вопрос задал фреймворк:

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import (
    AcceptedElicitation,
    CancelledElicitation,
    DeclinedElicitation,
    Elicit,
    ElicitationResult,
    Resolve,
)

mcp = MCPServer("Files")

_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}


class Confirm(BaseModel):
    ok: bool


async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
    """Resolver: ask for confirmation only when the folder is not empty."""
    file_count = len(_FOLDERS.get(path, []))
    if file_count == 0:
        return Confirm(ok=True)  # nothing to confirm, no round-trip to the client
    return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)


@mcp.tool()
async def delete_folder(
    path: str,
    confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
    """Delete a folder, asking for confirmation when it is not empty."""
    match confirm:
        case AcceptedElicitation(data=Confirm(ok=True)):
            _FOLDERS.pop(path, None)
            return f"deleted {path}"
        case AcceptedElicitation():
            return "kept the folder"
        case DeclinedElicitation():
            return "declined: folder not deleted"
        case CancelledElicitation():
            return "cancelled: folder not deleted"
  • confirm_delete читает по имени аргумент path самого инструмента, перечисляет содержимое папки и спрашивает только тогда, когда это необходимо — для пустой папки сразу возвращается Confirm(ok=True), без обмена с клиентом.
  • delete_folder указывает в аннотации ElicitationResult[Confirm], поэтому фреймворк внедряет результат целиком, а инструмент разбирает через match каждый случай: принять и подтвердить, принять, но оставить (ok=False), отказаться, отменить.
  • Параметр confirm никогда не попадает во входную схему инструмента — клиент передаёт path, резолвер передаёт confirm.

Если ветвление инструменту не нужно, укажите в аннотации саму модель без обёртки (Annotated[Confirm, Resolve(confirm_delete)]): при согласии инструмент получает модель, а при отказе или отмене вызов прерывается с ошибкой.

Резолвер работает на любом подключении. Клиенту на подключении старого поколения SDK отправляет вопрос напрямую; на подключении 2026-07-28 SDK возвращает вопрос из вызова, а следующая попытка клиента несёт ответ. Резолвер разницы не замечает; что происходит внутри — на странице Многораундовые запросы (multi-round-trip).

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

Вопрос изнутри инструмента

Инструмент может и сам остановиться посреди своего тела и спросить.

Warning

ctx.elicit() и ctx.elicit_url() — это запросы от сервера к клиенту, а такой канал существует только для клиента на подключении старого поколения (версия спецификации 2025-11-25 или более ранняя). На подключении 2026-07-28 запросов по инициативе сервера нет, поэтому эти вызовы завершаются ошибкой. Резолвер работает в обоих случаях. Подробнее — на странице Версии протокола.

await ctx.elicit() принимает сообщение и модель Pydantic:

server.py
from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Bistro")


class AlternativeDate(BaseModel):
    accept_alternative: bool = Field(description="Try another date?")
    date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)")


@mcp.tool()
async def book_table(date: str, party_size: int, ctx: Context) -> str:
    """Book a table at the bistro."""
    if date != "2025-12-25":
        return f"Booked a table for {party_size} on {date}."

    result = await ctx.elicit(
        message=f"No tables for {party_size} on {date}. Would you like to try another date?",
        schema=AlternativeDate,
    )
    if result.action == "accept" and result.data.accept_alternative:
        return await book_table(result.data.date, party_size, ctx)
    return "No booking made."
  • Параметр Context — это то, что даёт ctx.elicit; принять его может любой инструмент. У этого объекта есть своя страница: Объект Context.
  • AlternativeDateсхема нужного ответа.
  • Инструмент объявлен как async def. Иначе нельзя: он останавливается посреди выполнения и ждёт человека.
  • На любую другую дату инструмент отвечает сразу. Спрашивает он только тогда, когда приходится.
  • Дата, которую принял пользователь, снова проходит через сам book_table. Ответ — такой же ввод, как и любой другой: если альтернативная дата тоже полностью занята, о ней спросят ещё раз, а не подтвердят вслепую.

Что получает клиент

Клиент получает ваше сообщение, а рядом с ним — JSON Schema, сгенерированную из модели:

{
  "properties": {
    "accept_alternative": {
      "description": "Try another date?",
      "title": "Accept Alternative",
      "type": "boolean"
    },
    "date": {
      "default": "2025-12-26",
      "description": "Alternative date (YYYY-MM-DD)",
      "title": "Date",
      "type": "string"
    }
  },
  "required": ["accept_alternative"],
  "title": "AlternativeDate",
  "type": "object"
}

Эта схема и есть форма. Field(description=...) — подпись поля; значение по умолчанию заранее заполняет поле ввода и делает его необязательным. Это тот же механизм преобразования Pydantic в JSON Schema, который страница Инструменты описывает для аргументов инструмента.

Warning

Схема элицитации не так выразительна, как входная схема инструмента. Только плоские примитивные поля: str, int, float, bool или Literal из строк (он становится enum). Вложите модель в модель — и ctx.elicit выбросит исключение ещё до того, как что-либо уйдёт клиенту:

TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition

Вы прерываете человека посреди задачи. Если ответу нужна вложенность, он должен был быть аргументом инструмента.

Три ответа

result.action говорит, что сделал пользователь, и вариантов ровно три:

  • "accept": пользователь отправил форму. result.data — экземпляр AlternativeDate, уже прошедший валидацию.
  • "decline": пользователь отказался.
  • "cancel": пользователь закрыл вопрос, ничего не выбрав.

result.data существует только при "accept", поэтому пример сначала проверяет result.action. Средство проверки типов следит за этим порядком: после result.action == "accept" result.data — это AlternativeDate; до этой проверки никакого .data нет вообще.

Отказ — не ошибка. Инструмент сам решает, что означает отказ (здесь — бронь не создаётся), и отвечает модели как обычно.

Tip

Ответ проверяется по вашей модели до того, как его увидит ваш код. Клиент, приславший "maybe" вместо bool, не испортит бронирование: вызов завершится ошибкой несоответствия схеме, а ваш if так и не выполнится.

Отправка пользователя по URL

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

server.py
from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Bistro")


@mcp.tool()
async def pay_deposit(booking_id: str, ctx: Context) -> str:
    """Take the deposit that confirms a booking."""
    result = await ctx.elicit_url(
        message="A 20 EUR deposit confirms your booking.",
        url=f"https://pay.example.com/deposit/{booking_id}",
        elicitation_id=f"deposit-{booking_id}",
    )
    if result.action == "accept":
        return "Complete the payment in your browser."
    return "No deposit taken. The booking expires in one hour."


@mcp.tool()
async def confirm_deposit(booking_id: str, ctx: Context) -> str:
    """Record a payment reported by the payment provider."""
    await ctx.session.send_elicit_complete(f"deposit-{booking_id}")
    return f"Deposit received for booking {booking_id}."
  • ctx.elicit_url() принимает сообщение, URL, который нужно открыть, и выбранный вами elicitation_id — любую строку, идентифицирующую эту элицитацию в пределах сервера.
  • В результате есть действие и больше ничего. "accept" означает, что пользователь согласился открыть URL, а не что он завершил то, что находится по ту сторону.
  • Оплата происходит вне протокола, между браузером пользователя и вашим платёжным провайдером. Никакое содержимое через MCP обратно не приходит.

Взгляните на второй инструмент. Когда сервер узнаёт, что внешний процесс завершился (вебхук, опрос; здесь это смоделировано как второй инструмент), ctx.session.send_elicit_complete(...) отправляет notifications/elicitation/complete с тем же elicitation_id. Так клиент узнаёт, что можно перестать показывать «ожидание оплаты…». Без этого клиенту остаётся только гадать.

Сторона клиента

Серверы спрашивают. Клиенты отвечают, передавая elicitation_callback в Client(...):

client.py
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult


async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    if isinstance(params, ElicitRequestURLParams):
        print(f"Open this link to continue: {params.url}")
        return ElicitResult(action="accept")
    print(params.message)
    return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"})


async def main() -> None:
    async with Client(
        "http://127.0.0.1:8000/mcp",
        mode="legacy",
        elicitation_callback=handle_elicitation,
    ) as client:
        result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
        print(result.content)
  • Один колбэк обслуживает оба режима. params — объединение ElicitRequestFormParams и ElicitRequestURLParams; ветвление делается через isinstance.
  • Для URL вы показываете пользователю params.url и возвращаете выбранное им действие. Никакого content.
  • Для формы настоящее приложение отображает params.requested_schema и возвращает ввод пользователя в content. Этот колбэк всегда соглашается с заготовленным ответом — ровно то, что нужно в тесте.
  • Передача колбэка — это ещё и объявление возможности: так сервер узнаёт, что этому клиенту можно задавать вопросы. Остальное, на что клиент может отвечать серверу, — на странице Колбэки клиента.

Info

Элицитация — запрос от сервера к клиенту, а такие запросы существуют только в сессии с классическим рукопожатием, поэтому этот клиент передаёт mode="legacy". На подключении 2026-07-28 инструмент вместо этого спрашивает, возвращая вопрос из вызова; этот сценарий — Многораундовые запросы.

Попробуйте сами

Запустите server.py с ctx.elicit в режиме формы (тот, что с book_table) на Streamable HTTP (однострочная команда есть на странице Запуск сервера), затем запустите main() клиента и попросите у book_table столик на Рождество.

Колбэк печатает присланный ему вопрос:

No tables for 2 on 2025-12-25. Would you like to try another date?

Он отвечает {"accept_alternative": True, "date": "2025-12-27"}, и инструмент, всё это время ждавший внутри await ctx.elicit(...), завершает бронирование:

Booked a table for 2 on 2025-12-27.

Теперь подставьте server.py в режиме URL и направьте тот же main() на pay_deposit: тот же колбэк идёт по другой ветке, печатает ссылку на оплату, а инструмент возвращает «Complete the payment in your browser.». Один раунд обмена, посреди вызова, в обе стороны.

Check

Теперь уберите elicitation_callback= из Client и снова вызовите book_table на Рождество. Весь вызов завершается ошибкой протокола:

Elicitation not supported

Клиент, не зарегистрировавший колбэк, не объявил возможность elicitation, так что спрашивать некого. Инструмент получил не "decline", а исключение. Учитывайте это при проектировании: у каждой элицитации должен быть разумный ответ на вопрос «а что, если спросить нельзя?».

Итоги

  • Параметр с аннотацией Annotated[T, Resolve(fn)] заполняет резолвер, который возвращает Elicit(...), когда нужно спросить. Это работает на любом подключении.
  • Схема — плоская модель Pydantic: только примитивные поля, ответ проверяется на обратном пути.
  • result.action — это "accept", "decline" или "cancel"; result.data существует только при accept.
  • await ctx.elicit(message, schema=Model) спрашивает изнутри тела инструмента, а await ctx.elicit_url(message, url, elicitation_id) — для всего, что не должно проходить через модель (ctx.session.send_elicit_complete(elicitation_id) сообщает, что внешняя часть завершена). Оба — запросы от сервера к клиенту: клиент должен быть на подключении старого поколения.
  • Клиент отвечает одним elicitation_callback, ветвясь по типу params; его регистрация и объявляет возможность.
  • На подключении 2026-07-28 сервер возвращает вопрос, а не отправляет его сам; тот же колбэк получает вопросы через Многораундовые запросы.

Всё, что стоит за этим возвратом (цикл повторных попыток, защита requestState, самостоятельное управление процессом), — на странице Многораундовые запросы.