僕は一人会社を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つあります。
-
クライアントが対応していないと使えない。
initializeのレスポンスでcapabilities.elicitationが返ってくるかを必ず見る。 -
スキーマはフラットなオブジェクトのみ。 トップレベルのプロパティは
string/number/boolean/enumのプリミティブだけ。ネストした object や array は仕様上サポート外で、クライアントによっては黙って無視されます。 -
レスポンスは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 サーバー開発入門』 — ツール設計、トランスポート選択、クライアント対応の差分、実運用での監視まで