1
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でユーザーに問い合わせるMCPサーバーを書く — 承認待ちを「対話」に変える

1
Posted at

僕は一人会社をClaude Codeで回していて、対外アクション(SNS投稿・請求書発行・デプロイ)は必ず「draft → 承認 → 実行」のパイプラインを通す運用にしています。ただこの承認、最初はMarkdownのキューに積んで後から人間が読む形にしていて、実行が止まるのが地味に痛かった。

MCPの elicitation を使うと、ツール実行の途中でサーバー側からクライアント(=Claude Code)経由でユーザーに問い合わせ、答えを受けて処理を続行できます。承認が「あとで読むファイル」から「その場の対話」になる。この記事はその実装です。

elicitationとは何か(30秒)

MCPは通常 client → server の一方向のツール呼び出しですが、elicitation はその逆向きのリクエストです。

Claude Code ──tools/call──▶ MCPサーバー
                            │
            ◀─elicitation/create─┤  「本番にデプロイしますか?」+ JSON Schema
            ──{action, content}─▶│
                            │
            ◀──tool result──────┘

重要な制約が3つあります。

  1. クライアントが対応していないと使えない。 initialize のレスポンスで capabilities.elicitation が返ってくるかを必ず見る。
  2. スキーマはフラットなオブジェクトのみ。 トップレベルのプロパティは string / number / boolean / enum のプリミティブだけ。ネストした object や array は仕様上サポート外で、クライアントによっては黙って無視されます。
  3. レスポンスは3状態。 accept(入力あり)/ decline(明示的に拒否)/ cancel(ダイアログを閉じた)。decline と cancel を同一視すると事故ります(後述)。

作るもの

僕が実際に動かしている承認パイプラインのMCP化です。既存のシェル実装はこんな形で、承認結果をAPIに投げて Slack に流して git commit します。

# .company/scripts/auto-approve.sh(抜粋・実運用コード)
result=$(api_post "approvals/auto-approve" '{}')

approved_count=$(echo "$result" | jq -r '.approved | length')
if [ "$approved_count" -gt 0 ]; then
  approved_ids=$(echo "$result" | jq -r '.approved | join(", ")')
  notify_slack ":white_check_mark: [AI-CEO] 自動承認完了: ${approved_ids} (${approved_count}件)" || true
fi
git_auto_commit "auto: 自動承認処理 (${approved_count}件)"

これは閾値内の自動承認専用で、閾値を超えたものは人間待ちになります。その「人間待ち」を elicitation で埋めます。

実装

セットアップ

mkdir mcp-approval && cd mcp-approval
npm init -y
npm pkg set type=module
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist

サーバー本体

src/index.ts。ポイントは server.server.elicitInput() でサーバー発のリクエストを投げるところです(高レベルの McpServer ではなく、内側の低レベル Server インスタンスに生えています)。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const run = promisify(execFile);

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

server.registerTool(
  "request_approval",
  {
    title: "対外アクションの承認を得る",
    description:
      "SNS投稿・請求書発行・デプロイなど対外影響のあるアクションを実行する前に、" +
      "必ずこのツールで人間の承認を得る。承認されるまで実行してはならない。",
    inputSchema: {
      action: z.string().describe("実行しようとしているアクション"),
      detail: z.string().describe("投稿本文・金額などの具体的な内容"),
      amountJpy: z.number().optional().describe("金銭が絡む場合の金額(円)"),
    },
  },
  async ({ action, detail, amountJpy }) => {
    // 1. クライアントがelicitation非対応なら、従来のdraftキューに落とす
    const caps = server.server.getClientCapabilities();
    if (!caps?.elicitation) {
      await queueDraft({ action, detail, amountJpy });
      return {
        content: [
          {
            type: "text",
            text: "このクライアントはelicitation非対応。承認キューに積んだので実行は保留。",
          },
        ],
      };
    }

    // 2. ユーザーに問い合わせる。スキーマはフラット必須
    const res = await server.server.elicitInput({
      message:
        `【承認依頼】${action}\n` +
        (amountJpy != null ? `金額: ¥${amountJpy.toLocaleString()}\n` : "") +
        `内容:\n${detail}`,
      requestedSchema: {
        type: "object",
        properties: {
          decision: {
            type: "string",
            title: "判断",
            enum: ["approve", "approve_with_edit", "reject"],
          },
          editedDetail: {
            type: "string",
            title: "修正後の内容(approve_with_editのときだけ)",
          },
          note: { type: "string", title: "備考・却下理由" },
        },
        required: ["decision"],
      },
    });

    // 3. 3状態をきちんと分岐する
    if (res.action === "cancel") {
      return {
        content: [{ type: "text", text: "承認ダイアログが閉じられた。未決のまま中断する。再試行してよい。" }],
      };
    }
    if (res.action === "decline") {
      await appendDecision({ action, decision: "declined-by-user" });
      return {
        content: [{ type: "text", text: "ユーザーが承認を拒否した。このアクションは実行しない。" }],
        isError: true,
      };
    }

    const { decision, editedDetail, note } = res.content as {
      decision: string;
      editedDetail?: string;
      note?: string;
    };

    if (decision === "reject") {
      await appendDecision({ action, decision: "rejected", note });
      return {
        content: [{ type: "text", text: `却下。理由: ${note ?? "(未記入)"}。代替案を立てて再提案せよ。` }],
        isError: true,
      };
    }

    const finalDetail = decision === "approve_with_edit" && editedDetail ? editedDetail : detail;
    await appendDecision({ action, decision, note });

    return {
      content: [
        {
          type: "text",
          text: `承認された。実行してよい内容は以下の通り(CEOの修正を反映済み):\n${finalDetail}`,
        },
      ],
    };
  },
);

// 既存の意思決定ログにそのまま追記する(実運用の .company/decisions/ と同じ形式)
async function appendDecision(d: Record<string, unknown>) {
  const month = new Date().toISOString().slice(0, 7);
  const line = `- [${new Date().toISOString()}] ${JSON.stringify(d)}\n`;
  const { appendFile } = await import("node:fs/promises");
  await appendFile(`.company/decisions/${month}.md`, line, "utf8");
}

async function queueDraft(d: Record<string, unknown>) {
  const { appendFile } = await import("node:fs/promises");
  await appendFile(".company/approval-queue.md", `- [AQ] ${JSON.stringify(d)}\n`, "utf8");
}

await server.connect(new StdioServerTransport());

登録

npx tsc
claude mcp add approval-gate -- node "$PWD/dist/index.js"

.mcp.json に直接書く場合:

{
  "mcpServers": {
    "approval-gate": {
      "command": "node",
      "args": ["./mcp-approval/dist/index.js"],
      "env": { "TZ": "Asia/Tokyo" }
    }
  }
}

あとは CLAUDE.md に一行入れておけば、エージェントが勝手にこのゲートを通ります。

対外に影響のあるアクションは、実行前に必ず `request_approval` ツールで承認を得る。
承認結果が approve 以外の場合、そのアクションを実行してはならない。

検証方法

ダイアログを出さずに素で叩けます。initialize で elicitation capability を申告するのがコツ。

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"elicitation":{}},"clientInfo":{"name":"probe","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"request_approval","arguments":{"action":"X投稿","detail":"新刊のお知らせ","amountJpy":0}}}' \
 | node dist/index.js

elicitation/create の リクエストがサーバー側から流れてくるのが見えれば成功です(この手打ちテストは応答を返さないのでそこで止まります)。対話込みで試すなら npx @modelcontextprotocol/inspector node dist/index.js が早い。

capability を申告しない形("capabilities":{})でも叩いて、queueDraft 側にフォールバックすることも必ず確認してください。ここを確認せずに本番投入すると、非対応クライアントで elicitInput() が例外になってツールごと落ちます。

踏んだ落とし穴

ネストしたスキーマは通らない。 最初 { items: [{id, decision}] } のような配列で「承認待ち5件を一度にさばく」UIを作ろうとして、丸ごと無視されました。仕様がフラットなプリミティブ限定なので、1アクション1回の問い合わせにループを分解するのが正解です。件数が多いときは先に enum で「全部承認 / 1件ずつ見る / 全部却下」を聞いてから分岐すると回数が減ります。

decline と cancel を混ぜない。 cancel(ダイアログを閉じた)を「拒否」として記録すると、席を外していただけで却下ログが残ります。僕は cancel を未決として扱い、isError も立てずに「再試行してよい」と返すようにしました。逆に decline は isError: true にして、エージェントが勝手に続行しないようにしています。

タイムアウト。 人間が返事をするまでサーバーは待ちます。cronから無人で走るパスでこのツールを呼ぶと永久に止まるので、無人実行では capability が無い=キューに落ちる、という設計に寄せました。自動承認(閾値内)は従来の auto-approve.sh のまま、閾値超えだけ elicitation、という二階建てです。

承認プロンプトに全文を入れる。 「SNS投稿を承認しますか?」だけだと中身が見えず、CEO(=僕)が結局ファイルを開くことになって意味がない。投稿本文・金額をメッセージに全部埋めると、朝の確認が本当に数十秒で終わります。

結び

承認待ちをキューに積む運用は、安全ではあるけれど人間がボトルネックになる場所をコードの外に出してしまう設計でした。elicitation はそのボトルネックをツールの内側に引き戻してくれる。止まるのは同じでも、止まった瞬間に聞かれるので再開が速い。

実際、承認の平均リードタイムが「翌朝まとめて」から「その場」に変わって、1日に回せる対外アクションの本数が増えました。ガバナンスを緩めずにスループットを上げられるのが、この機能のいちばん経営的な価値だと思っています。

MCPサーバー開発をもっと深く

MCPサーバーの設計・認証・実運用までまとめた当社の書籍があります。

  • 📘 『Claude Code × MCP サーバー開発入門』 — ツール設計、トランスポート選択、クライアント対応の差分、実運用での監視まで

書籍一覧: https://zenn.dev/joinclass?tab=books

1
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
1
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?