Элицитация
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
Инструменту, который уже наполовину сделал свою работу и которому не хватает одного ответа, не обязательно завершаться ошибкой.
Элицитация (elicitation) позволяет ему спросить. Прямо посреди вызова инструмента пользователь получает вопрос, а его ответ возвращается в тот же самый вызов функции.
Есть два режима:
- Режим формы: нужно значение (подтверждение, дата, количество). Вы описываете поля, клиент отображает форму.
- Режим URL: нужно, чтобы пользователь перешёл куда-то ещё (экран согласия OAuth, страница оплаты). Ничто из того, что он там делает, не проходит через протокол.
И есть два способа спросить. Предпочтительный — резолвер: вопрос привязывается к параметру, а SDK задаёт его сам — на любом подключении, какого бы поколения протокол ни использовал клиент. Прямой способ, await ctx.elicit(...), — это запрос от сервера к клиенту, а такой канал существует только для клиента на подключении старого поколения (версия спецификации 2025-11-25 или более ранняя). На этой странице описаны оба; начните с резолвера.
Вопрос с помощью резолвера
Вопрос, от которого зависит весь инструмент, — вы уверены? какой из трёх подходящих аккаунтов? — можно вынести из тела инструмента в резолвер, и фреймворк задаст его за вас.
Параметр с аннотацией Annotated[T, Resolve(fn)] заполняется результатом вызова fn перед телом инструмента. Резолвер возвращает значение напрямую, если уже знает его, или возвращает Elicit(...), чтобы вопрос задал фреймворк:
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:
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. В таких случаях вы просите не данные, а просите пользователя куда-то перейти:
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(...):
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, самостоятельное управление процессом), — на странице Многораундовые запросы.