🔌

管理画面を作るのはもうやめる。業務ツールは MCP 起点で十分だった

に公開

BtoB の営業支援ツールを作っています。企業を集めて、サイトを解析して、営業文面を作って、人が承認して送る。よくある業務システムです。

そして、よくある管理画面を作りました。一覧と詳細、絞り込み、モーダル、一括操作のチェックボックス。ひと通り揃っています。

途中から操作を MCP 経由に移したところ、その画面がほとんど使われなくなりました

最初は「日常操作だけがチャットへ移り、重要な操作は画面に残る」と考えていました。承認や送信は人が画面で押すべきだ、と。しかし運用してみると、そう思っていた根拠のほうが崩れました。画面でなければ満たせないと思っていた要件は、画面を要求していませんでした

この記事は、そこに至るまでの記録です。結論から言うと、業務ツールに管理画面はいりません。

画面を磨いても解決しない問題があった

前提として、この業務には構造的な壁がありました。

対象業種の企業のうち、公式サイトの URL を確認できたのは母集団の数 % です。多くの企業はメールアドレスを公開していません。つまり 到達できる相手の数は、画面をどれだけ改善しても増えません

この状況で開発者が時間を使うべき場所は、一覧のソート順やモーダルの挙動ではありません。データソースを増やすこと、判定の精度を上げること、1 人あたりの処理件数を上げることです。

管理画面は、その時間を静かに食っていました。

入口を増やすと、必ず食い違う

MCP を足すとき、最初に事故が起きました。設計の話をする前に、こちらを先に書きます。

このツールには「企業除外リスト」「送信抑止リスト」「送信根拠の有効期限」「日次上限」「静穏時間」といったガードがあります。承認の処理は当時 3 か所にありました。画面の Route Handler、MCP のツール、そして一括承認の Route Handler です。それぞれが似たコードを持っていました。

問題は一括承認でした。1 件ずつの承認はガードを通していたのに、一括承認だけが通していなかったのです。

操作 企業除外の確認 抑止リストの確認 送信根拠の鮮度
1 件ずつ承認
まとめて承認

「まとめて承認」を選ぶだけで検査が緩くなる。担当者から見れば、どちらも同じ「承認」です。

直し方は素朴でした。状態遷移をアプリケーションサービスへ集約し、入口は Actor を組み立てて呼ぶだけの薄い層にしました。

/**
 * 操作者。画面(セッション)と MCP(API トークン)の違いを、ここで 1 つに揃える。
 *
 * アプリケーションサービスは Actor しか受け取らない。入口ごとに別の型を渡せてしまうと、
 * 「画面では効くが MCP では効かない検査」が生まれる。それを型で防ぐのがこの層の役割。
 */
export interface Actor {
  organizationId: string
  userId: string | null
  role: UserRole
  /** 操作経路。監査ログへ残して画面操作と MCP 操作を区別する */
  via: "ui" | "mcp"
}

そして一括処理は、1 件ずつの処理をそのまま順に呼びます。

/**
 * まとめて処理するときだけ検査を省く、ということをしないための作り。
 */
for (const draftId of draftIds) {
  try {
    succeeded.push(await approveDraft(actor, { draftId }))
  } catch (error) {
    failed.push({ id: draftId, error: message(error) })
  }
}

ここまでは、よくある「サービス層を切りましょう」という話です。しかし運用しながら、この事故の教訓はもう一段先にあると思うようになりました。

入口が増えるほど、検査は食い違う。なら入口は増やさないほうがいい。

管理画面は入口です。しかも、いちばん実装が重い入口です。

「承認は画面で押すべき」を疑う

とはいえ、当初は承認と送信だけは画面に残すつもりでした。要件の絶対条件として「送信の最終判断は必ず人間が行う」と決めており、法令上の証跡としても、誰がいつ承認・送信したかを記録する必要があるからです。

しばらくして気づきました。この要件は 「人間が判断すること」を求めていて、「画面で押すこと」は求めていません

証跡として必要なのは次の 3 つです。

  1. 操作者が実在の人物として特定できること
  2. 何を承認・送信したかが記録されていること
  3. 承認から送信までの間に、人の意思が挟まっていること

MCP 経由でも、3 つとも満たせます。

1 について。 MCP の認証は API トークンですが、トークンは必ず実在の利用者へ紐づけます。失効・期限切れ・利用停止は弾きます。ここを緩めると「誰が承認・送信したか」を追跡できなくなり、証跡として成立しません。逆に言えば、ここさえ守れば画面のセッションと同じ強度です。

2 について。 これは画面もチャットも変わりません。同じサービス層が同じ監査ログを書きます。むしろ via を残しているぶん、画面より情報が多いくらいです。

3 について。 ここが本題でした。送信ツールの入力スキーマを、こうしています。

confirmed: z
  .literal(true)
  .describe("送信対象と件数を利用者へ提示し、承諾を得た場合にだけ true を指定します")

エージェントは宛先と本文と件数を利用者へ提示し、承諾を得るまで実行できません。画面のボタンがやっていたのは、まさにこれです。 「内容を見せて、人が承知したうえで実行する」という手続きが本体であって、ボタンという形は本体ではありませんでした。

しかも、チャットのほうが提示できる情報は多い。ボタンの隣に注記を並べるより、「この 12 社へ送ります。うち 2 社は前回送信から 45 日しか空いていません」と書くほうが、判断材料としてよほど親切です。

私たちは長いあいだ、「人間の最終判断」と「画面のクリック」を同じものだと思っていました。分けて考えたら、画面が要る理由は残りませんでした。

「一覧を俯瞰したい」を疑う

次に残ると思っていたのが、送信キューや操作ログの一覧です。50 件並んだ中から赤い行を 1 件見つけるのは目のほうが速い。これは今でもそう思います。

ただし、それは 作り込んだ画面が必要 という意味ではありませんでした。

必要なのは「そのとき見たい切り口のビュー」です。作り込んだ一覧は、作った時点で切り口が固定されます。列を増やしたければ実装が要る。ソート順を変えたければ実装が要る。運用で本当に見たい切り口は、たいてい後から分かります。

チャットの戻り値には、その制約がありません。たとえば設定変更のツールは、変更後の設定に加えて「その設定で 1 日に実際に送れる件数」を必ず返します。

return {
  ...settings,
  // 上限を上げただけでは届かない場合があるため、実際に送れる件数を必ず返す
  sendCapacity: calculateSendCapacity(settings),
}

日次上限を 500 に上げても、送信間隔 60 秒と静穏時間の組み合わせで実際には 541 件が上限になる。この手の「設定の相互作用」は、画面だと小さな注記になりがちです。戻り値なら毎回目に入ります。

さらに言えば、表が見たければその場で描かせればいい。集計軸を変えたければ言い直せばいい。使い捨てのビューが即座に手に入るなら、恒久的な一覧を実装して保守する理由は薄いです。

「全員が Claude を持っているわけではない」を疑う

最後の反論がこれでした。人を増やすとき、全員に環境を配れるとは限らない。

これは正しい指摘ですが、設計の問題ではなく配布の問題 です。そして配布は時間で解決します。3 年前なら決定的な反論でしたが、今は違う。少なくとも「だから管理画面を作り込む」という結論にはなりません。

正直に書くと、私たちのプロダクトには今も画面が残っています。ただしそれは過渡期の都合であって、設計上の要請ではありません。同じサービス層を通るので、消しても業務は止まりません。消せる状態にあることと、消してあることは違いますが、方向は決まっています。

残っている本当の課題

きれいな話ばかり書いたので、いま困っていることも書きます。

新しい設定項目を追加してデプロイした直後、同じ Claude セッションから設定を変えようとすると、こう返ってきました。

Input validation error: formFallbackToEmail: Invalid input: expected boolean, received string

サーバーは新しい引数を認識しています。「boolean を期待した」と言えているので、コードは新しい。ではなぜ文字列が届くのか。

MCP クライアントは、接続した時点のツール一覧と入力スキーマを保持し続けるから です。手元のスキーマにその項目が無いため、値が boolean として型付けされずに送られていました。同じ理由で、新しく追加したツールはセッションのツール一覧に存在すらしませんでした。

MCP で機能を追加しても、「デプロイしたら使える」にはならない。開いているセッションを開き直すまで、新機能は存在しない。

Web アプリならリロードで済む話が、MCP ではセッションの再接続になります。本人には「使えない」としか見えないので、サーバー側の不具合と誤診しやすい。

これは痛い制約です。ただし プロトコルとクライアントの未成熟さであって、設計の否定ではありません。Web も昔はキャッシュの罠だらけでした。ここは近いうちに埋まると思っています。

結論

管理画面に何か月もかける時代は終わったと思っています。

私たちが画面に求めていたものを分解すると、こうでした。

画面に求めていたもの 本当に必要だったもの
承認ボタンを押す 内容を提示され、人が承知すること
誰が押したかの記録 操作者を実在の人物として特定できること
一覧で俯瞰する そのとき見たい切り口のビュー
全員が使える入口 全員に環境が配られていること

右の列は、どれも画面を要求していません。左の列を実装していた工数は、判定の精度とデータソースの拡張へ回せます。 私たちの場合、そちらのほうが事業に効きました。

今から同じものを作るなら、こうします。

  1. 状態遷移とガードをアプリケーションサービスへ集約する
  2. 入口は MCP だけにする
  3. 画面は、作らない

3 が言い過ぎに聞こえるなら、こう言い換えます。画面を作りたくなったら、それは 1 で集約し損ねたガードがあるか、ツールの戻り値の情報が足りていないかのどちらかです。 私たちの場合、振り返ると全部そうでした。

GitHubで編集を提案
YOSHINANI

Discussion