メインコンテンツへスキップ
ブログ一覧

Claude Code MCP Elicitation実践ガイド — MCPサーバーからユーザー入力を要求する双方向ワークフローの構築

Claude CodeMCPElicitationHooksCI/CD

MCPサーバーは「呼ばれたら応える」受動的な存在だった。2026年3月、Claude Code v2.1.76で追加されたElicitation機能がこの前提を覆す。MCPサーバーがタスク実行中にユーザーへ構造化フォームを提示し、入力を待ち、その結果に基づいて処理を分岐できるようになった。本記事では、formモードの実装からHooksによるカスタム制御、さらにheadlessモードのdefer決定を組み合わせたCI/CD承認ゲートまで、実際に動くコード付きで解説する。

Elicitationとは何か — MCPの「逆方向」通信

従来のMCP通信フローとの違い

従来のMCPはクライアント→サーバーの一方向リクエストだった。クライアントが tools/call でツールを呼び、サーバーが結果を返す。ユーザーの意思決定が必要な場面でも、サーバー側から能動的に問い合わせる手段はなかった。

Elicitationはこの方向を逆転させる。プロトコルメソッド elicitation/create により、サーバー→クライアント→ユーザーという逆方向の問い合わせが可能になった。モードは2種類ある。

  • formモード — クライアント内に構造化フォームを表示し、入力データをMCPチャネル経由で受け取る
  • urlモード — ブラウザでURLを開き、OAuthやAPI Key入力など機密データをMCP外で処理する
json
// formモード: サーバー→クライアントへのリクエスト
{
  "method": "elicitation/create",
  "params": {
    "message": "デプロイ先の環境を選択してください",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "environment": {
          "type": "string",
          "enum": ["staging", "production"],
          "description": "デプロイ環境"
        },
        "confirm": {
          "type": "boolean",
          "description": "本当にデプロイしますか?"
        }
      },
      "required": ["environment", "confirm"]
    }
  }
}

3つのレスポンスアクション: accept / decline / cancel

ユーザーの応答は3種類に分かれる。

アクション 意味 contentフィールド
accept 入力を送信 フォームデータを含む
decline 明示的に拒否 なし
cancel ダイアログを閉じた(Escキーなど) なし

サーバー側はこの3パターンすべてをハンドリングする必要がある。正直、最初は「declineとcancelを分ける必要あるのか?」と思ったが、ユーザーが意図的に拒否したのか、単に閉じてしまったのかで後続処理を変えたい場面は確かにある。

スキーマの制約: requestedSchema はフラットなオブジェクトとプリミティブ型のみ。ネストしたオブジェクトや配列は使えない。複雑な入力が必要な場合は、複数回のElicitationに分割する設計が必要になる。

formモード実装 — MCPサーバーから構造化フォームを提示する

MCPサーバー側の実装(TypeScript SDK)

MCP TypeScript SDKの server.server.elicitInput() を使った実装例を示す。デプロイ承認を求めるMCPツールの完全な実装だ。

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "deploy-gate",
  version: "1.0.0",
});

server.tool(
  "deploy",
  { service: { type: "string", description: "デプロイ対象サービス" } },
  async ({ service }, { server: mcpServer }) => {
    // Elicitationでユーザーに承認を求める
    const result = await mcpServer.elicitInput({
      message: `${service} をデプロイします。環境を選択してください。`,
      requestedSchema: {
        type: "object",
        properties: {
          environment: {
            type: "string",
            enum: ["staging", "production"],
            description: "デプロイ先環境",
          },
          skip_tests: {
            type: "boolean",
            description: "テストをスキップする",
          },
        },
        required: ["environment"],
      },
    });

    // accept / decline / cancel の3パターンを必ずハンドリング
    if (result.action === "decline") {
      return {
        content: [{ type: "text", text: "デプロイがユーザーにより拒否されました。" }],
      };
    }

    if (result.action === "cancel") {
      return {
        content: [{ type: "text", text: "デプロイがキャンセルされました。再度実行してください。" }],
      };
    }

    // accept: 入力値を使って処理続行
    const env = result.content?.environment as string;
    const skipTests = (result.content?.skip_tests as boolean) ?? false;

    // 実際のデプロイ処理...
    return {
      content: [{
        type: "text",
        text: `${service} を ${env} にデプロイしました(テストスキップ: ${skipTests})`,
      }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

フォームフィールド設計パターン

ユースケース別に、よく使うフィールド設計を3つ紹介する。

1. デプロイ前確認(boolean) — 最もシンプル。Yes/Noの1問で済む場面に。

2. 環境選択(enum)enum で選択肢を提示するパターンが最も実用的。ユーザーに自由入力させるとtypoリスクがある場面で重宝する。

3. パラメータ入力(string + number複合) — バージョン番号やレプリカ数など、複数の入力を一画面で収集する。

Elicitation Hooksでクライアント側を制御する

Elicitation / ElicitationResult フックの仕組み

Claude CodeのHooksシステムを使うと、Elicitationの挙動をクライアント側でカスタム制御できる。.claude/settings.json に定義する。

フック入力として標準入力に渡されるJSONには、session_idmcp_server_nametool_nametool_inputform_fields の5フィールドが含まれる。

終了コードの意味は以下の通り。

  • 0: 成功。stdoutのJSONに従って処理
  • 2: ブロック。Elicitationを拒否

自動承認・自動拒否・ログ記録の実装例

特定のMCPサーバーからのElicitationのみ自動承認し、それ以外はブロックする例を示す。

json
// .claude/settings.json
{
  "hooks": {
    "Elicitation": [
      {
        "matcher": "",
        "hooks": [{
          "type": "command",
          "command": "node /path/to/elicitation-gate.mjs"
        }]
      }
    ]
  }
}
javascript
// elicitation-gate.mjs
import { readFileSync } from "fs";

const input = JSON.parse(readFileSync("/dev/stdin", "utf-8"));

// 信頼済みMCPサーバーリスト
const TRUSTED_SERVERS = ["deploy-gate", "config-manager"];

if (TRUSTED_SERVERS.includes(input.mcp_server_name)) {
  // 自動承認: デフォルト値で応答
  const content = {};
  for (const field of input.form_fields) {
    if (field.type === "boolean") content[field.name] = true;
  }
  console.log(JSON.stringify({
    hookSpecificOutput: {
      hookEventName: "Elicitation",
      action: "accept",
      content,
    },
  }));
} else {
  // 信頼されていないサーバーはブロック
  process.stderr.write(`Blocked elicitation from: ${input.mcp_server_name}`);
  process.exit(2);
}

ElicitationResultフックでは、ユーザーの入力内容を外部に転送できる。監査ログやSlack通知に活用する場面で有用だ。

headless + defer で実現するCI/CD承認ゲート

defer決定とは — headlessセッションの一時停止と再開

v2.1.89で追加されたPreToolUseフックの defer 決定は、headlessセッションを一時停止する仕組みだ。ツールは実行されず、プロセスは stop_reason: "tool_deferred" で終了する。セッション状態はディスクに保存され、--resume で再開できる。

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "defer"
  }
}

制約として、deferが機能するのはClaudeが単一のツール呼び出しを行ったターンに限られる。複数ツールの同時呼び出し時はdeferは無視される。

GitHub Actions承認ゲートの構築例

Elicitation + defer を組み合わせた、CI/CDパイプラインでの承認フローの概要を示す。

yaml
# .github/workflows/deploy.yml
name: Deploy with approval
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Claude headless
        id: claude
        run: |
          result=$(claude -p "deploy-gateツールでproductionデプロイを実行して" \
            --headless \
            --permission-mode default \
            --output-format json)
          echo "result=$result" >> "$GITHUB_OUTPUT"

      - name: Check for deferred tool
        id: check
        run: |
          stop_reason=$(echo '${{ steps.claude.outputs.result }}' | jq -r '.stop_reason')
          session_id=$(echo '${{ steps.claude.outputs.result }}' | jq -r '.session_id')
          echo "stop_reason=$stop_reason" >> "$GITHUB_OUTPUT"
          echo "session_id=$session_id" >> "$GITHUB_OUTPUT"

      - name: Wait for Slack approval
        if: steps.check.outputs.stop_reason == 'tool_deferred'
        run: |
          # Slack承認ボタン送信 → Webhook待受
          # 承認後にresumeを実行
          claude -p --resume ${{ steps.check.outputs.session_id }} \
            --permission-mode default

タイムアウト設計も忘れてはならない。deferされたセッションに有効期限はないため、CI/CD側でジョブタイムアウトを設定し、期限切れ時はセッションを破棄する運用が必要だ。

ハマりポイントと制約事項

Elicitationを実装する上で、事前に知っておくべき制約をまとめる。

スキーマはフラットオブジェクトのみ。 ネストや配列が必要なら、複数回のElicitationに分割する。ただし1つのツール呼び出し内で何度でも elicitInput() を呼べるので、段階的に聞いていく設計は可能だ。

URLモードのエラーコード -32042 (URLElicitationRequiredError)。ツール呼び出しへのエラーレスポンスとして返され、クライアントにURL遷移を促す。notifications/elicitation/complete 通知後にリトライする設計が必要になる。

Elicitation非対応クライアントへの後方互換。 これは地味だが重要なポイントだ。capabilities.elicitation の存在チェックを必ず行い、非対応時はデフォルト値で続行するフォールバックを用意する。

typescript
server.tool("smart-deploy", schema, async (args, { server: mcpServer }) => {
  // クライアントがElicitation対応かチェック
  const capabilities = mcpServer.getClientCapabilities?.();
  
  if (!capabilities?.elicitation) {
    // 非対応: デフォルト値で続行
    return executeDeploy(args.service, "staging", false);
  }

  // 対応: ユーザーに確認
  const result = await mcpServer.elicitInput({ /* ... */ });
  // ...
});

タイムアウト設計。 ユーザーが長時間応答しない場合、サーバー側のリクエストがハングする可能性がある。AbortController でタイムアウトを設けることを推奨する。

まとめ — 自律と介入の境界を設計する

Elicitationは「完全自律」と「常時監視」の間に、必要な瞬間だけ人間を介入させる設計を可能にした。

使い分けの指針は3層で考えるとよい。

  1. formモード — 日常的な確認・入力。デプロイ先選択、パラメータ指定など
  2. Hooks — 組織ポリシーに基づく自動制御。信頼済みサーバーの自動承認、監査ログ
  3. defer + resume — CI/CDレベルの承認フロー。外部システム(Slack、GitHub)との連携

MCPサーバーを設計する際は、「このツールのどのステップでユーザー判断が必要か」を最初に考えることが重要だ。すべてを自動化するのでもなく、すべてに確認を挟むのでもなく、判断が必要な瞬間を見極めてElicitationを配置する。それが、自律エージェント時代のヒューマン・イン・ザ・ループ設計の要点になる。

もっと読む他の技術記事も読む