Elicitation/ElicitationResultフックでMCP入力要求を横取りする
MCPサーバーがユーザー入力を求めるElicitationと、応答を仲介するElicitationResult。2つのフックで自動応答・記録・機密情報要求のブロックまでを扱います。
MCPサーバーがツール呼び出しの途中でユーザーに追加入力を求めると、Claude Codeは既定で対話ダイアログを表示します。Elicitationフックはこの要求そのものを横取りして自動応答に置き換え、ElicitationResultフックはその応答が実際にサーバーへ送り返される直前でもう一度観察・上書き・ブロックできます。CIやheadless運用でダイアログを人手で処理できない場面や、どのサーバーが何を求めたかを監査ログに残したい場面で、この2つが自動化の入口になります。要求そのものがフォームモードかURLモードかという仕様面の違いはMCPのElicitation、フォームとURLで何が違うかで扱っており、本記事はその要求をClaude Code側でどう横取り・検査するかというフック実装に絞ります。
要求から送信まで、2つのフックはどこに入るか
Elicitationは、MCPサーバーがツール呼び出しを処理している最中に「情報が足りない」と判断したとき、クライアント経由でユーザーに追加入力を求めるMCPの機能です。Claude Codeのフックライフサイクル図では、ElicitationとElicitationResultはどちらもMCPツール実行の内側にネストされたイベントとして描かれています。
1回の入力要求が通る道筋
- 1
サーバーが入力を求める
ツール呼び出しの途中で、MCPサーバーが要求を送ります。ここで
Elicitationフックが発火します。 - 2
フックが応答するか、ダイアログが開く
フックが
hookSpecificOutputを返せば、ダイアログは表示されません。何も返さなければ、通常のダイアログがユーザーに出ます。 - 3
応答が確定する
ユーザーの入力、またはフックの自動応答で、
actionとcontentが決まります。 - 4
ElicitationResultフックが発火する
確定した応答を、サーバーへ送る前に観察・上書き・ブロックできます。
- 5
サーバーへ送信される
上書きされた場合は、上書き後の内容が送られます。
この2つには他の主要フックと違う制約があります。PreToolUse hookでツール実行前に許可・拒否・改変するで扱ったPreToolUseやStopは、コマンド・HTTP・MCPツール・プロンプト・エージェントの5種類すべてのハンドラー型に対応します。ElicitationとElicitationResultが対応するのはcommand・http・mcp_toolの3種類だけです。LLMに判断させるprompt型・agent型のハンドラーはこの2つでは使えません。認証情報のやり取りに関わる以上、判定ロジックは決定的なスクリプトで書くことになります。
もう一点、この2つのフックはsystemMessageとcontinueを捨てます。Claude Codeが見るのはhookSpecificOutputだけです。exit 2のときの標準エラー出力も、画面のどこにも表示されません。ブロックした理由をユーザーに伝えたいなら、ログへ書くか、後述のNotificationフックのような別のチャネルを使います。
どこで定義でき、遅いスクリプトはどう扱われるか
定義できる場所は、他のcommand/http/mcp_tool対応イベントと同じです。ユーザー単位の~/.claude/settings.json、プロジェクト単位の.claude/settings.json、Skillやサブエージェントのfrontmatterまで、スコープの選び方は変わりません(定義場所や設定階層の全体はClaude Code Hooks完全ガイドにまとめています)。
相性が良いのはサブエージェントのfrontmatterです。特定のサブエージェントだけが機密性の高いMCPサーバーを呼ぶ設計なら、そのサブエージェントの定義ファイルにフックを閉じ込められます。メインの会話には影響しません。共通入力には、サブエージェント内でフックが発火したときだけ付くagent_idとagent_typeのフィールドがあります。
ハンドラー型はcommandのほかにhttpとmcp_toolも選べます。http型なら複数プロジェクトの判定を1台のポリシーサーバーに集約できます。サーバーは2xxのJSONボディで同じhookSpecificOutputを返します。2xxでもJSON以外のボディ(プレーンテキストなど)や、2xx以外のステータスは非ブロッキングのエラー扱いです。HTTPフックはステータスコードだけではブロックを伝えられないので、拒否したいときも2xxでJSONを返します。
mcp_tool型なら、判定を接続済みの別のMCPサーバーのツールに委ねられます。たとえば社内の権限管理システムをMCPサーバー化し、「このサーバーのこの要求は許可してよいか」をそのツールに問い合わせる構成です。
タイムアウトの既定値は、command・http・mcp_toolで600秒です。async: trueを付けない限り、timeoutに達したフックは取り消され、出力は破棄されます。多くのイベントでは、タイムアウトしたフックは決定を何も出さなかったことになります。ElicitationとElicitationResultは、PreModelSwitchのように「タイムアウトで拒否」と明記されたイベントではありません。外部APIへの照会のように時間のかかる処理を入れるなら、timeoutを明示しておくと挙動が読みやすくなります。
matcherはMCPサーバー名だけを見る
PreToolUseのようなツールイベントはmcp__<server>__<tool>という名前でMCPツールを識別し、mcp__memory__.*のようなワイルドカードでサーバー単位に絞り込みます。ElicitationとElicitationResultのmatcherはこの命名規則に乗りません。matcherが評価するのはツール名ではなく、MCPサーバー名そのものです。
{
"hooks": {
"Elicitation": [
{
"matcher": "internal-crm",
"hooks": [{ "type": "command", "command": "/path/to/handle-crm-elicitation.sh" }]
}
]
}
}internal-crmのようにサーバー名を裸で書けば完全一致になり、mcp__internal-crm__.*と書いても一致しません。全サーバーが対象ならmatcherを省略するか"*"にします。文字種で完全一致か正規表現かが決まる点は他のイベントと同じで、英数字・アンダースコア・ハイフン・空白・カンマ・|だけなら完全一致です。server-a|server-bのように区切って複数サーバーを列挙できます。
入力に何が届き、何を返せるか
Elicitationフックには、共通入力フィールドに加えてmcp_server_nameとmessageが届きます。mode・url・elicitation_id・requested_schemaは任意のフィールドです。フォームモードならmode: "form"と、要求するデータのJSON Schemaであるrequested_schemaが付きます。
{
"hook_event_name": "Elicitation",
"mcp_server_name": "my-mcp-server",
"message": "Please provide your credentials",
"mode": "form",
"requested_schema": {
"type": "object",
"properties": { "username": { "type": "string", "title": "Username" } }
}
}URLモードならmode: "url"と、ブラウザで開かせたいurlが付きます。URLモードの要求では、フォームの値は存在しません。
応答を横取りするには、hookSpecificOutputにactionを入れてJSONを返します。contentはフォームの入力値で、actionがacceptのときだけ使われます。exit 2で要求そのものを拒否することもできますが、このとき同時に出力したhookSpecificOutputは無視されます。ブロックしたいだけなら、JSONを書かずexit 2だけで済ませるのが素直です。
actionの3値は、MCPの仕様でこう区別される
accept
ユーザーが明示的に承認して送信した状態です。フォームモードでは
contentに入力値が入り、URLモードではcontentがありません。decline
ユーザーが明示的に断った状態です。仕様上、サーバーは断られた場合の代替案を出す想定です。ポリシーで拒否する自動応答は、この値が意味に合います。
cancel
ユーザーが選ばずに閉じた状態です。ダイアログを閉じる、Escを押す、ブラウザが開けなかった、といった場合が例に挙がっています。サーバーは後で再度尋ねる扱いにできます。
ElicitationResultは応答を送り返す直前の関所
ElicitationResultは、ユーザーまたはElicitationフックが応答した後、内容がサーバーへ送信される前に発火します。入力はmcp_server_nameとactionに加え、mode・elicitation_id・contentが任意で届きます。requested_schemaは届かないので、要求側の中身と突き合わせたいときはelicitation_idでElicitation側のログと結びます。
出力側でactionやcontentを上書きすれば、いったん確定した応答を差し替えられます。exit 2を返すと、有効なアクションが強制的にdeclineに変わります。
{
"hookSpecificOutput": {
"hookEventName": "ElicitationResult",
"action": "decline",
"content": {}
}
}この位置が効くのは、サーバーがフォームモードでパスワードやAPIキーを要求してきた場合です。MCPの仕様は、パスワード・APIキー・アクセストークン・決済情報のような機密情報をフォームモードで求めることを禁じ、そうした用途にはURLモードを使うよう定めています。名前やメールアドレス、ユーザー名のような一般的な連絡先情報は、同じ仕様で一律の禁止対象にはなっていません。仕様に反する実装が現れたとき、ユーザーが気づかずacceptした応答でも、contentをElicitationResultで検査してから送信を止められます。クライアント側に置ける最後の防御線です。
フックのページは発火の時点を「After a user responds」(ユーザーが応答した後)と書いています。送信直前の検査はElicitationResult側に置いておくと、Elicitationで自動応答した経路でも発火するかどうかに左右されずに済みます。
2つのフックの役割分担
Elicitation
応答はまだ存在せず、これから作る段階です。ダイアログを出さずに自動応答したい、定型のパターン向きです。
ElicitationResult
actionとcontentがすでに決まっています。想定外の値が紛れていないかを見る、最終チェック向きです。
同じ判定ロジックを両方に二重に仕込むより、役割を分けたほうが、それぞれのスクリプトを単純に保てます。VS Code拡張でダイアログが出ないまま辞退される報告は、MCP elicitationが自動辞退される不具合にまとめています。
レシピ: すべての要求と応答をログに残す
監査目的なら、応答を変えずにログだけ残すのが最も安全です。matcherを省略して全サーバーを対象にし、exit 0で通常のダイアログ挙動をそのまま通します。
#!/bin/bash
# Elicitation/ElicitationResult: 要求と応答をJSON Lines形式で記録する
input=$(cat)
log_dir="$HOME/.claude/elicitation-log"
mkdir -p "$log_dir"
jq -c --arg ts "$(date -u +%FT%TZ)" '. + {logged_at: $ts}' <<<"$input" \
>> "$log_dir/$(date -u +%F).jsonl"
exit 0同じスクリプトを両方のイベントに登録すれば、「何を要求されたか」と「何を送り返したか」を突き合わせられます。フォームモードなら、Elicitation側のrequested_schemaとElicitationResult側のcontentを比べる形です。要求と応答はどちらもelicitation_idを持てるので、1件ごとに結びつけられます。
レシピ: 信頼できないサーバーの機密情報要求をブロックする
許可リストに無いサーバーがフォームモードでパスワードらしき値を求めてきたら、ダイアログを出す前に拒否します。標準エラー出力は画面に出ないので、拒否の記録はファイルに残します。
#!/bin/bash
# Elicitation: 許可リスト外サーバーの機密情報らしき要求を自動拒否する
input=$(cat)
server=$(jq -r '.mcp_server_name' <<<"$input")
mode=$(jq -r '.mode' <<<"$input")
message=$(jq -r '.message' <<<"$input")
trusted=("internal-crm" "billing-server")
is_trusted=false
for s in "${trusted[@]}"; do
[[ "$server" == "$s" ]] && is_trusted=true
done
if [[ "$mode" == "form" && "$is_trusted" == false ]] \
&& grep -qiE 'password|api.?key|token|secret' <<<"$message"; then
echo "$(date -u +%FT%TZ) blocked: $server" >> "$HOME/.claude/elicitation-blocked.log"
exit 2
fi
exit 0このスクリプトは、許可リスト外のサーバーが「Enter your API key」を求める入力では終了コード2を返します。internal-crmが同じメッセージを求める入力なら、終了コードは0です。メッセージ文字列だけを見た簡易判定なので、requested_schemaのプロパティ名まで見て厳密にする拡張が次の一歩です。
レシピ: URLモードはドメインの許可リストで判定する
URLモードでは、機密情報のやり取りはクライアントの外のブラウザで行われます。MCPの仕様は、クライアントがURLを自動で先読みすることを禁じ、同意なしに開くことも禁じています。ユーザーには完全なURLを見せることが必須で、ドメインの強調表示も推奨されています。フックから見えるのもurlの文字列だけなので、判定の根拠にできるのはホスト名になります。
#!/bin/bash
# Elicitation: URLモードはHTTPSかつ許可リスト内のホストだけ通す
input=$(cat)
[[ "$(jq -r '.mode' <<<"$input")" == "url" ]] || exit 0
host=$(jq -r '.url' <<<"$input" | sed -E 's#^https://([^/:?]+).*#\1#')
case "$host" in
auth.example.com|login.example.org) exit 0 ;;
esac
exit 2このスクリプトでは、https://auth.example.com/loginは終了コード0で通ります。https://auth.example.com.evil.test/loginのように許可ホストを前置きに使ったURLや、http://で始まるURLは終了コード2で拒否されます。ホスト名を前方一致や部分一致で比べず、caseで完全一致させているのはそのためです。
ダイアログが開いたことを別経路で知らせる
ElicitationとElicitationResultはsystemMessageを捨てるので、人間への通知はNotificationフックに任せる構成が取れます。Notificationのmatcherには、MCPの入力要求に対応する種別が4つあります。
入力要求に関係するNotificationの種別
elicitation_dialog
MCPサーバーがフォームを開き、約6秒入力がないときに発火します。
elicitation_url_dialog
ブラウザでURLを開くよう求められ、約6秒入力がないときに発火します。
elicitation_complete
サーバーが、URLモードの入力要求の完了を報告したときに発火します。
elicitation_response
入力要求の応答がサーバーへ送り返されたときに発火します。
端末に張り付いていないときだけ通知が届くのは、権限確認のプロンプトと同じ約6秒のゲートを共有しているためです。キー入力のたびにタイマーは先送りされます。
{
"hooks": {
"Notification": [
{
"matcher": "elicitation_dialog|elicitation_url_dialog",
"hooks": [{ "type": "command", "command": "/path/to/notify.sh" }]
}
]
}
}Notificationは観察用のイベントで、応答を変えることはできません。自動で答えるのはElicitation、知らせるのはNotificationと使い分けます。
まとめ
まずは応答を変えないログ記録から入り、機密情報の混入を止めたくなった段階でブロックを足すのが、安全な順序です。MCPサーバーの権限設計まで含めて見直す場合はMCPセキュリティガイドも参考になります。