エリシテーション(elicitation)
このページは機械翻訳です
このドキュメントの翻訳版は英語版のページから自動的に生成されており、正式な内容はこのページの英語版です。
翻訳におかしな点を見つけましたか?翻訳の仕組みと問題の報告方法を参照してください。
処理の途中まで進んだツールが、答えを 1 つ欠いているからといって失敗する必要はありません。
エリシテーションを使えば、ツールの側から尋ねられます。ツール呼び出しの途中でユーザーに質問が表示され、その答えが同じ関数呼び出しの中へ戻ってきます。
モードは 2 つあります。
- フォームモード:値が必要な場合(確認、日付、数量など)。フィールドを記述すると、クライアントがフォームを描画します。
- URL モード:ユーザーに別の場所へ移動してもらう場合(OAuth の同意画面、決済ページなど)。そこでの操作はプロトコルを一切通りません。
そして、尋ね方も 2 通りあります。まず選ぶべきなのはリゾルバーです。質問をパラメーターに結び付けておけば、SDK が尋ねてくれます。どんな接続でも、クライアントが話すプロトコルの世代が何であっても動作します。直接的な方法である await ctx.elicit(...) は、サーバーからクライアントへのリクエストであり、このチャネルはレガシー接続(仕様バージョン 2025-11-25 以前)のクライアントにしか存在しません。このページでは両方を扱いますが、まずはリゾルバーから始めましょう。
リゾルバーで尋ねる
ツール全体の前提となる質問(「本当に実行しますか?」「一致した 3 つのアカウントのうちどれですか?」)は、ツール本体からリゾルバーへ切り出せます。あとはフレームワークが代わりに尋ねてくれます。
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)リクエスト を参照してください。
尋ねることは、リゾルバーができることの 1 つにすぎません。一般的な仕組み(尋ねずに計算する依存関係、依存関係の依存関係、モデルが供給できるものとできないもの)については、依存関係 のページを参照してください。
ツールの中から尋ねる
ツールは、自身の本体の途中で止まって尋ねることもできます。
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."
ctx.elicitを使えるようにしてくれるのがContextパラメーターです。どのツールでも受け取れます。このオブジェクトには専用のページがあります: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
相手は作業中の人間です。答えにネストが必要なら、それはツールの引数にすべきだったものです。
3 つの答え
result.action を見れば、ユーザーが何をしたか分かります。可能性はちょうど 3 つです。
"accept":フォームを送信しました。result.dataは検証済みのAlternativeDateインスタンスです。"decline":拒否されました。"cancel":選択せずに質問を閉じました。
result.data は "accept" の場合にしか存在しません。例で先に result.action を確認しているのはそのためです。型チェッカーもこの順序を強制します。result.action == "accept" の後では result.data は AlternativeDate ですが、その前には .data はそもそも存在しません。
拒否はエラーではありません。拒否が何を意味するか(ここでは予約しないこと)はツールが決め、モデルには通常どおり応答します。
Tip
答えは、コードが受け取る前にモデルで検証されます。bool に対して "maybe" を送る
クライアントがいても予約が壊れることはありません。呼び出しはスキーマ不一致のエラーで
失敗し、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を受け取ります。elicitation_idは、サーバー内でこのエリシテーションを識別できる任意の文字列です。- 結果にはアクションしか含まれません。
"accept"はユーザーが URL を開くことに同意したという意味であり、その先の手続きを完了したという意味ではありません。 - 決済は、ユーザーのブラウザーと決済プロバイダーの間で、プロトコルの外側で行われます。コンテンツが MCP を通って戻ってくることはありません。
2 つ目のツールを見てください。プロトコル外のフローが完了したことをサーバーが知ったとき(Webhook やポーリングなど。ここでは 2 つ目のツールとしてモデル化しています)、ctx.session.send_elicit_complete(...) が同じ elicitation_id を付けて notifications/elicitation/complete を送ります。これによってクライアントは「支払いを待っています...」の表示をやめてよいと分かります。これがないと、クライアントは推測するしかありません。
クライアント側
尋ねるのはサーバーです。クライアントは Client(...) に elicitation_callback を渡して答えます。
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)
- 1 つのコールバックで両方のモードを処理します。
paramsはElicitRequestFormParamsとElicitRequestURLParamsの union で、isinstanceが分岐点です。 - URL の場合は
params.urlをユーザーに提示し、選ばれたアクションを返します。contentは決して返しません。 - フォームの場合、実際のアプリケーションは
params.requested_schemaを描画し、ユーザーの入力をcontentとして返します。この例は常に決まった答えで承認しますが、テストではまさにこうしたコールバックが欲しくなります。 - コールバックを渡すことは、ケイパビリティの宣言でもあります。これによってサーバーは、このクライアントに尋ねられると分かります。クライアントがサーバーのために答えられるその他のものについては、クライアントコールバック を参照してください。
Info
エリシテーションはサーバーからクライアントへのリクエストであり、それは従来のハンドシェイクの
セッションにしか存在しません。このクライアントが mode="legacy" を渡しているのはそのためです。
2026-07-28 の接続では、ツールは呼び出しの戻り値として質問を返すことで尋ねます。
そのフローが マルチラウンドトリップリクエスト です。
試してみる
ctx.elicit を使うフォームモードの server.py(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.
次に URL モードの server.py に差し替え、同じ main() を pay_deposit に向けてみてください。同じコールバックがもう一方の分岐に入り、決済リンクを出力し、ツールは「Complete the payment in your browser.」を返します。呼び出しの途中で、双方向に 1 往復です。
Check
ここで Client から elicitation_callback= を外し、もう一度クリスマス当日で 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)が、プロトコル外の処理が終わったことを伝えます)。どちらもサーバーからクライアントへのリクエストなので、レガシー接続のクライアントが必要です。- クライアントは 1 つの
elicitation_callbackで答え、params の型で分岐します。これを登録することがケイパビリティの宣言になります。 - 2026-07-28 の接続では、サーバーは質問を送りつけるのではなく返します。同じコールバックに値を渡すのは マルチラウンドトリップリクエスト です。
その戻り値の裏側にあるもの(リトライループ、requestState の保護、自分で駆動する方法)はすべて マルチラウンドトリップリクエスト にあります。