🐎

LLMは確認指示を無視する - 127ツール入りMCP-firstフォームサービスで安全設計を強制した話

に公開

私は FORMLOVA というフォームサービスを作っています。MCP サーバーが主要なインターフェースで、現在私の知る限り世界最大数の127個のツールを25カテゴリに分けて、フォーム作成から回答分析、メール配信までをチャットで動かせます。4ヶ月かけてシナリオテストを回しながら積み上げた規模です。

この記事では、その設計の内側を書きます。何を作ったかではなく、なぜそう設計したか、何を試して何が駄目だったか。

前提: 会話を制御面にするとは

MCP-first という言葉を聞くと「MCPサーバーを公開しています」に聞こえるかもしれません。私が意味しているのはそこではありません。

会話を補助UIとして後付けするのではなく、最初から会話が運用の制御面になる前提でプロダクトを設計することです。回答を確認して、条件で絞り込んで、営業への通知文を作る。この一連が同じ会話の中でつながって進みます。

では具体的に、そこに至るまでに何が問題になったかを書いていきます。

LLMは確認指示を無視します

最初は INSTRUCTIONS(システムプロンプト)に「メール送信前に必ず確認を取れ」と書いていました。

結果は単純で、モデルは確認をスキップします。user_confirmed=true を最初の呼び出しで渡してきます。「確認しなさい」と書いても、モデルが「文脈から十分に判断できる」と判断すれば、確認ステップを飛ばして直接実行します。これは特定のモデルの問題ではありません。複数のモデル、複数のクライアントで再現しました。

つまり、プロンプト指示は安全制御に使えません。

ここが設計の起点になりました。確認を「お願い」するのではなく、サーバー側で「強制」する必要があります。これはUXの選択ではなく、安全設計の必然でした。

127ツールを爆風半径で分類する

サーバー側で制御するなら、まず127個のツール全てを「壊せる範囲」で分類する必要があります。

type OperationClass =
  | "inspect"        // 参照のみ -- 分析、エクスポート、一覧表示
  | "prepare"        // 可逆変更 -- デザイン変更、フィールド編集
  | "mutate"         // 回答者に波及 -- フォーム公開、非公開予約
  | "external-write" // 不可逆の外部副作用 -- メール送信、データ削除

これを4段階の安全レベルに対応させました。

Level 影響範囲 ツール数 制御方式
L0 読み取り専用 約50 即実行、確認なし
L1 可逆変更 約40 即実行、バージョン管理で復元可能
L2 回答者に波及 約4 サーバー主導の状態機械
L3 外部に不可逆 11 HMAC署名トークンによるハードブロック

設計判断として重要なのは、L0とL1を即実行にしたことです。全ツールに確認を入れると会話のテンポが壊れます。「分析を見せて」「デザインを変えて」のたびに確認が入ったら、会話はただ面倒になります。

逆に L3 の11ツール(メール送信5件、データ削除など6件)は、プロンプト指示に関係なくサーバーが止めます。LLMに判断を委ねないのが原則です。

confirmation_token: なぜHMAC-SHA256なのか

L3ツールの確認には暗号的なトークンを使っています。仕組みはこうです。

// トークンのペイロード -- ツール名、ユーザーID、対象リソースに紐づく
interface ConfirmationPayload {
  tool_name: string;
  user_id: string;
  resource_key: string;    // フォームID、メールバッチIDなど
  issued_at: number;
}
// トークン = HMAC-SHA256(secret, JSON.stringify(payload))
// TTL: 5分。1トークン = 1操作 = 1ユーザー = 1リソース

会話の流れはこうなります。

ユーザー: 「デモ希望の人に営業通知を送って」

  [LLM -> send_filtered_email (user_confirmed=false)]
  [サーバー -> 確認プロンプトを返す]

サーバー: 「問い合わせフォームの高温度回答2件に営業通知を送信します。
          対象: Olivia Carter, Sophia Nguyen
          件名: デモリクエストのフォローアップ
          送信ではなくdraft作成です。」
        + confirmation_token を発行

ユーザー: 「OK、送って」

  [LLM -> send_filtered_email (user_confirmed=true, token=xxx)]
  [サーバー -> HMAC検証、TTL検証、ツール名・ユーザー・リソース照合]
  [全て一致 -> 実行]

なぜ単純なフラグではなくHMACなのか。

LLMが初回の呼び出しで user_confirmed=true を送ってくる問題を思い出してください。フラグだけなら、モデルはそれを最初から true にできます。トークンは「サーバーが確認プロンプトを返した後にしか存在しない」ものです。トークンなしで user_confirmed=true を送っても、サーバーが拒否します。

5分のTTLは、ユーザーが確認内容を読む時間と、会話が別の話題に移った後に古いトークンが使い回されるリスクのバランスです。失効した場合はハードエラーにせず、新しい確認サマリーと新トークンを返して会話を継続できるようにしています。

preview URL開封トリガー: LLMの省略癖への物理的対策

publish_form にはもう一つ設計上の仕掛けがあります。フォーム公開前に、ユーザーがプレビューを実際に見たかをサーバーが検証します。

チャットで「プレビュー確認しました」と言うだけでは不十分です。なぜなら、そのテキストはLLMが生成できるからです。ユーザーが実際にプレビューを見ていなくても、モデルは「確認しました」と返せます。

そこで、プレビュー URL が実際にブラウザで開かれたかどうかをサーバー側で追跡しています。URL を開くという行為は、LLMが偽造できない検証可能なアクションです。publish_form は呼び出されるたびに現在の状態を返し、プレビューが開かれていなければ状態機械は先に進みません。

interface PublishReviewState {
  next_required_action: string | null;
  missing_requirements: string[];
  form_preview_url: string;
  thankyou_preview_url: string;
  form_preview_opened: boolean;       // 実際に開かれたか
  thankyou_preview_opened: boolean;   // 実際に開かれたか
  confirmation_token?: string;        // 全条件が揃ったときだけ発行
}

これもUX上の選択ではなく、LLMの省略癖に対する構造的な対策です。

管理画面を消そうとして失敗した話

MCP-first なら管理画面は不要だろうと考えた時期がありました。

まず、フォームビルダー(GUIでの作成画面)を作って外しました。フォームは入口であって、作成に時間をかけるのは本質ではありません。チャットで「問い合わせフォーム作って」と言えば済みます。

次に、管理画面そのものを消すことを検討しました。結果は明確に失敗でした。

フォームが何十個もあると、いちいちチャットで「あのフォームの今の状態は?」と聞くのはラリーが増えます。分析データを俯瞰したいときは、表やグラフが並んだ画面が圧倒的に速いです。会話は逐次的で、ダッシュボードは並列的。この性質の違いは設計では埋められません。

最終的にこうなりました。

  • チャットが主: 意図を伝える、条件を変える、次のアクションを決める、連続した操作を前に進める
  • 管理画面が従: 多数の項目を俯瞰する、状況を一覧で確認する、細かい設定を目視で確認する

これは妥協ではなく、2つのインターフェースがそれぞれ得意なことに合わせた結果です。

MCPを開発者ツールではなくエンドユーザーUXとして見る

FORMLOVA を作り始めたとき、MCPは開発者向けのプロトコルという見方が主流でした。

私が賭けたのは、MCPがエンドユーザーの業務インターフェースになるという見立てです。freee やマネーフォワードが MCP 経由の経理業務を発表したとき、この確信が裏付けられました。MCP は開発者ツールの枠を超えて、日常業務のインターフェースになりえます。

ただし、業務インターフェースにするには、開発者向けとは違う水準の安全設計が必要です。開発者は誤操作に気づけますし、元に戻す手段も知っています。エンドユーザーはそうではありません。

127ツールを4段階に分類し、サーバー側で確認を強制する設計は、この前提から来ています。

会話フロー: 回答 -> 振り分け -> 通知

公開後の問い合わせフォームで、一連の操作がどうつながるかを示します。この流れの実践例はホットリードを営業へつなげるガイドで詳しく解説しています。

ユーザー: 「問い合わせフォームの回答を意図ごとにまとめて」

  [LLM -> get_response_analytics]  (L0: 即実行)

サーバー: デモ希望 12件 / 比較検討 8件 / とりあえず 23件

ユーザー: 「デモ希望の人だけ見せて」

  [LLM -> filter_responses]  (L0: 即実行、フィルタ結果を保持)

サーバー: 12件の回答 (Olivia Carter, Sophia Nguyen, ...)

ユーザー: 「その中で温度感が高い人を営業に共有する通知を作って」

  [LLM -> send_filtered_email (user_confirmed=false)]  (L3: トークン発行)

サーバー: 「2件の高温度回答について営業通知を作成します。
          対象: Olivia Carter, Sophia Nguyen
          件名: Hot Lead -- デモリクエスト
          これはdraft作成です。送信しますか?」

ユーザー: 「送って」

  [LLM -> send_filtered_email (user_confirmed=true, token=xxx)]
  [サーバー -> トークン検証 -> 送信実行]

技術的に重要なのは、ステップ2のフィルタ結果がステップ3に引き継がれていることです。モデルが再取得や再フィルタをする必要がありません。この文脈の連続性があるから、会話が制御面として成立します。

4ヶ月で127ツール: 積み上げ方

4ヶ月で127ツールを作りました。一気に設計したのではなく、シナリオテストを回しながら積み上げました。

「フォームを作って公開する」という基本シナリオから始めて、「回答を分析する」「条件で絞り込む」「メールを送る」と操作を広げるたびに、足りないツールが見えてくる。そのつど追加し、安全レベルを割り当て、テストを書く。

結果として24カテゴリに収まりました。forms、responses、analytics、emails、webhooks、team、templates、profile、discovery、connect、filtering、extraction、pulse、conversational、crm、optimizer、ab-testing、response-management、scheduling、email-sequences、smart-notifications、memory、traces、google-sheets。

まとめ

MCP-first で安全な運用インターフェースを作るために必要だった設計判断を整理します。

  1. LLMのプロンプト指示は安全制御に使えない -- だからサーバー側で強制する
  2. 全ツールを爆風半径で分類する -- 即実行すべきものと止めるべきものを分ける
  3. 確認はHMAC署名トークンで強制する -- フラグだけではモデルが迂回する
  4. 物理的に検証可能なアクションを使う -- プレビューのURL開封など、LLMが偽造できない確認手段
  5. 管理画面は消さない -- 会話と画面はそれぞれ得意な仕事が違う

この設計が正しいかどうかは、もうすぐわかります。

FORMLOVA は無料で始められます。フォーム1つ作って公開後の操作を試せば、この安全設計が実際にどう動くか体感できるはずです。


この設計の思想的な背景や、すぐ試せるガイドはこちらで書いています:

Discussion