エリシテーション
処理の途中で答えがひとつ足りないだけのツールは、失敗する必要はありません。
エリシテーション(elicitation)を使えば、質問できます。ツール呼び出しの途中でユーザーに質問が届き、その答えが同じ関数呼び出しの中に戻ってきます。
モードは 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 requests) で説明しています。
質問は、リゾルバーにできることのひとつにすぎません。質問せずに計算する依存関係、依存関係の依存関係、モデルが渡せるものと渡せないものといった仕組み全般は、依存関係 のページで説明しています。
ツールの中から質問する
ツールは、自分の本体の途中で止まって質問することもできます。
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
人の作業を中断して質問しているのです。答えに入れ子が必要なら、それはツールの引数にすべきでした。
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を受け取ります。これは、サーバー内でこのエリシテーションを識別できる任意の文字列です。- 結果にあるのはアクションだけです。
"accept"はユーザーが URL を開くことに同意したという意味で、向こう側でやるべきことを終えたという意味ではありません。 - 決済は、ユーザーのブラウザーと決済プロバイダーの間で、帯域外で行われます。MCP を通って戻ってくる内容は一切ありません。
2 つ目のツールを見てください。帯域外のフローが終わったことをサーバーが知ったとき(webhook やポーリング。ここでは 2 つ目のツールとしてモデル化しています)、ctx.session.send_elicit_complete(...) が同じ elicitation_id を付けて notifications/elicitation/complete を送ります。これによってクライアントは、「waiting for payment...」の表示をやめてよいと分かります。これがなければ、クライアントは推測するしかありません。
クライアント側
質問するのはサーバーです。クライアントは、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のユニオンで、isinstanceで分岐します。 - URL の場合は、
params.urlをユーザーに見せ、ユーザーが選んだアクションを返します。contentは決して返しません。 - フォームの場合、本物のアプリケーションなら
params.requested_schemaを描画し、ユーザーの入力をcontentとして返します。このコールバックは決まった答えで常に「はい」と答えます。これはまさに、テストで欲しいコールバックです。 - コールバックを渡すことは、ケイパビリティの宣言でもあります。これによってサーバーは、このクライアントに質問できることを知ります。クライアントがサーバーの代わりに答えられるほかのことは、クライアントのコールバック にまとまっています。
Info
エリシテーションはサーバーからクライアントへのリクエストであり、それは従来のハンドシェイクによるセッションにしか存在しません。このクライアントが mode="legacy" を渡しているのはそのためです。2026-07-28 の接続では、ツールは代わりに呼び出しから質問を「返す」ことで質問します。その流れは マルチラウンドトリップリクエスト で説明しています。
試してみる
ctx.elicit を使うフォームモードの server.py(book_table のほう)を Streamable HTTP で起動し(1 行で起動するコマンドは サーバーの実行 にあります)、クライアントの 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は承諾のときにだけ存在します。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 の保護、自分で駆動する方法)は、すべて マルチラウンドトリップリクエスト で説明しています。