はじめに / 対象と前提
MCP サーバーのツールを書いていると、必ずこの壁にぶつかる。
ツールを実行し始めてから「この情報が足りない」と気づいた。でも引数はもう確定していて、途中でユーザーに訊く手段がない。
これを解決するのが MCP の Elicitation(エリシテーション)。ツールの実行中に、サーバー側からクライアント経由でユーザーへ入力フォームを出せる仕組みだ。
- 想定読者: MCP サーバーを自作していて、ツール実行中の対話的な入力が欲しい人
- 前提知識: MCP のツールを 1 つでも書いたことがある / Python の async が読める
-
検証環境: Python 3.13 / MCP Python SDK(
mcp、requires-python >=3.10)/ MCP 仕様 2025-06-18 以降
TL;DR
- サーバーが
elicitation/createを投げ、クライアントがユーザーに訊いて返す。主導権はクライアント側 - Python SDK なら
await ctx.elicit(message=..., schema=Pydanticモデル)の 1 行。返り値のactionはaccept/decline/cancelの 3 値 - 機密情報(トークン・カード番号)はフォームで訊いてはいけない。仕様で MUST NOT。代わりに URL モード
ctx.elicit_url()を使う - 最大のハマりどころは「クライアントが非対応だと問答無用でエラーが返る」こと。フォールバックを書かないと本番で落ちる
流れとしては、tools/call の返答を返す前にサーバーがクライアントへ逆向きのリクエスト(back-channel)を投げる、という形になる。
手順1: サーバー側(form モード)
訊きたい項目を Pydantic モデルで定義して、ctx.elicit() に渡すだけ。
from pydantic import BaseModel, Field
from mcp.server.mcpserver import Context, MCPServer
mcp = MCPServer(name="Elicitation Example")
class BookingPreferences(BaseModel):
"""ユーザーに訊きたい項目"""
checkAlternative: bool = Field(description="別の日程を確認しますか?")
alternativeDate: str = Field(
default="2024-12-26", description="代替日 (YYYY-MM-DD)"
)
@mcp.tool()
async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str:
"""席を予約する(空きが無ければユーザーに訊き直す)"""
if date == "2024-12-25":
result = await ctx.elicit(
message=f"{date} は {party_size} 名の空きがありません。別の日程にしますか?",
schema=BookingPreferences,
)
# ここが重要: accept 以外を成功扱いしない
if result.action == "accept" and result.data:
if result.data.checkAlternative:
return f"[SUCCESS] {result.data.alternativeDate} で予約しました"
return "[CANCELLED] 予約しませんでした"
return "[CANCELLED] キャンセルされました"
return f"[SUCCESS] {date} {time} で予約しました"
result.data は渡した Pydantic モデルのインスタンスとして返るので、型付きで触れる。
手順2: クライアント側(受け口を実装する)
サーバーだけ書いても動かない。クライアントが elicitation capability を宣言していないと成立しない。Python SDK なら ClientSession にコールバックを渡す。
from mcp import ClientSession, types
async def handle_elicitation(
context, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
print(params.message)
print(params.requestedSchema) # ここからフォーム UI を組み立てる
return types.ElicitResult(
action="accept",
content={"checkAlternative": True, "alternativeDate": "2026-09-01"},
)
# ClientSession(read, write, elicitation_callback=handle_elicitation) として渡す
SDK は「コールバックが渡されたか」で initialize 時の capability 宣言を切り替える。渡さなければ elicitation は宣言されない。
ハマりどころ
1. 非対応クライアントでは即エラーになる(フォールバック必須)
elicitation_callback を渡していないクライアントに ctx.elicit() を投げると、SDK のデフォルト実装がこれを返す。
INVALID_REQUEST: Elicitation not supported
ツール呼び出しごと失敗する。手元の対応クライアントでは動くのに、別のホストに繋いだ瞬間に全滅、という事故が起きる。対策は 2 つ。
- サーバー側で
check_client_capability()を見て、非対応なら elicit をスキップする - ツール引数に同じ項目をオプショナルで生やし、elicit できない時は「引数で渡してください」とエラー文で返す
後者のほうが実用的だった。LLM は自然言語のエラーを読んで、次のターンで引数付きで呼び直してくれる。
if not ctx.session.check_client_capability(
types.ClientCapabilities(elicitation=types.ElicitationCapability())
):
return "このクライアントは対話入力に非対応です。alternative_date 引数を指定して再実行してください。"
なお、トランスポートに逆向きの経路が無いと NoBackChannelError が飛ぶ。ステートレスな HTTP 構成では起こりうるので try/except で拾っておくと安全。
2. requestedSchema はフラットなプリミティブしか通らない
仕様上、requestedSchema は フラットなオブジェクト + プリミティブのみに制限されている。
| 使える | 使えない |
|---|---|
string(minLength / format: email,uri,date,date-time) |
ネストした object
|
number / integer(minimum / maximum) |
オブジェクトの配列 |
boolean(default) |
oneOf / $ref などの高度な機能 |
enum + enumNames
|
クライアントがフォーム UI を自動生成できるようにするための制限なので、諦めてフラット化する。Pydantic でネストしたモデルを渡すと描画できずに死ぬ。list[str] すら安全ではないので、カンマ区切りの str にして自前で split するのが無難だった。
3. 機密情報を form モードで訊くのは仕様違反
仕様にはこう書かれている。
Servers MUST NOT use elicitation to request sensitive information.
API キーやパスワードをフォームで訊いてはいけない。SDK には URL モードがあり、OAuth や決済のような「外部で完結させるべき操作」はブラウザへ飛ばす。
import uuid
@mcp.tool()
async def secure_payment(amount: float, ctx: Context) -> str:
elicitation_id = str(uuid.uuid4())
result = await ctx.elicit_url(
message=f"${amount:.2f} の支払いを確認してください",
url=f"https://payments.example.com/confirm?id={elicitation_id}",
elicitation_id=elicitation_id,
)
if result.action == "accept":
return "ブラウザで決済を完了してください"
return "拒否またはキャンセルされました"
認可なしではツールが 1 ミリも進めない場合は、UrlElicitationRequiredError を送出する手もある。SDK がこれを -32042 エラーに変換してクライアントへ返す。
補足: FastMCP が ImportError になる時
from mcp.server.fastmcp import FastMCP で落ちたら SDK のバージョン差。現在は mcp.server.mcpserver.MCPServer にリネームされている。古い記事のコードをコピペするとここで詰まる。
from mcp.server.mcpserver import Context, MCPServer # 新
from mcp.server.fastmcp import Context, FastMCP # 旧
まとめ
- Elicitation はツール実行の途中でユーザーに訊ける唯一の正攻法。引数を増やして無理やり解決しなくていい
-
accept/decline/cancelは必ず 3 分岐で書く。contentが入るのはacceptの時だけ - 非対応クライアントでは
Elicitation not supportedで落ちる。capability チェックか引数フォールバックを必ず用意する - スキーマはフラットなプリミティブのみ。ネストは通らない
- 機密情報は form モードで訊かず、URL モード(
elicit_url)に逃がす
対話が必要なツールは「引数を増やして LLM に埋めさせる」設計になりがちだが、それだと LLM が値をでっち上げる。ユーザー本人に訊けるのは事故防止の意味でも大きい。