0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP サーバーの Elicitation でツール実行中にユーザーへ質問する実装 — 非対応クライアントで即死する等3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

MCP サーバーのツールを書いていると、必ずこの壁にぶつかる。

ツールを実行し始めてから「この情報が足りない」と気づいた。でも引数はもう確定していて、途中でユーザーに訊く手段がない。

これを解決するのが MCP の Elicitation(エリシテーション)。ツールの実行中に、サーバー側からクライアント経由でユーザーへ入力フォームを出せる仕組みだ。

  • 想定読者: MCP サーバーを自作していて、ツール実行中の対話的な入力が欲しい人
  • 前提知識: MCP のツールを 1 つでも書いたことがある / Python の async が読める
  • 検証環境: Python 3.13 / MCP Python SDK(mcprequires-python >=3.10)/ MCP 仕様 2025-06-18 以降

TL;DR

  • サーバーが elicitation/create を投げ、クライアントがユーザーに訊いて返す。主導権はクライアント側
  • Python SDK なら await ctx.elicit(message=..., schema=Pydanticモデル) の 1 行。返り値の actionaccept / decline / cancel3 値
  • 機密情報(トークン・カード番号)はフォームで訊いてはいけない。仕様で 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 が値をでっち上げる。ユーザー本人に訊けるのは事故防止の意味でも大きい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?