MCPサーバーとエージェントホスト、なぜ二つに分けたのか

MCPサーバーとエージェントホスト、なぜ二つに分けたのか

「一つにまとめればプロジェクトが一つ減るのに」と考えた瞬間から、実際の問題解決が始まりました。

社内プラットフォームに、自然言語でサービスを操作できるAIエージェントを組み込むことにしました。しかし最初の問いは「何を作るか」ではなく、「何をいくつ作るか」でした。対話UIを既存サービスの画面内に置くことにしたため、MCPサーバーとエージェントホストを一つのプロジェクトにまとめてもよいのか、判断する必要がありました。

まとめる案は魅力的に見えました。デプロイ単位が一つ減り、プロセス間通信もなくなり、コードを行き来する必要もありません。実際、最初はその方向で検討しました。

結論は分離でした。そして後になって、この決定が単にプロジェクトをいくつ置くかという問題ではなかったことが明らかになりました。制御をどこに置くのか、承認画面の定義を誰が所有するのかといった問いが、すべてこの境界上で決まったからです。

この記事では、二つのプロジェクトに分けた根拠と、その境界上で制御階層を設計する中で経験した試行錯誤をまとめます。制御を二か所に置いた結果、外側の階層が内側の階層のより良い応答を横取りしたこと、そして承認画面の定義を描画する側が持っていたため、静かな失敗が再生産されていた構造について扱います。

1. 技術選定の背景 — なぜ一つにまとめてはいけないのか

判断がつかなかった理由は、MCPが何のためのプロトコルなのか、私には不明確だったからです。その問いを正面から考えてみると、答えが出ました。MCPはプロセス境界を越えるためのプロトコルです。同じプロセス内で使うなら、MCPは自分自身にJSON-RPCを送る構造になります。つまり、統合した瞬間にMCPを使う理由そのものがなくなります。

この観点に立つと、残りの根拠も見えてきました。

  • 制御の原則が成立しません。ホストが承認しても、MCPサーバーがポリシーによって最終再検証するという二重の制御は、二つの階層が実際に分離されていてこそ意味を持ちます。

  • MCPサーバーは複数のホストから共有されます。社内エージェントだけでなく、外部MCPクライアントも同じサーバーに接続します。ホストに依存したサーバーは、その時点で再利用できなくなります。

  • レイテンシーとコストの特性が正反対です。LLM呼び出しは遅く高価で、ツールの取得は速く安価です。一緒にデプロイすると、互いに足を引っ張ります。

検討の過程で見送った選択肢もありました。既製のエージェントSDKを使えばプロジェクト数を減らせるのではないかという案でしたが、二つの理由から適していませんでした。社内スタックはJavaですが、そのSDKはPythonとTypeScriptしかサポートしておらず、何よりプロジェクト数も減らせませんでした。言語が異なれば統合できないため、むしろ分離が強制されます。

この議論の中で、もう一つ整理できた誤解がありました。エージェントを直接作るということは、推論を直接作るという意味ではないという点です。判断は常に汎用LLMが行い、私たちが作るのは実行ループとツール接続、そしてポリシーと監査です。そこで原則を一行にまとめました。推論は汎用LLMに委ね、実行制御はプラットフォームが直接所有する。

2. 適用プロセスと実装

2.1 二つのプロセスと責任の分担

構成は二つのサービスに確定しました。MCPサーバーはツール一覧とマップ、そしてポリシー・承認・監査を担当します。エージェントホストは対話処理とLLM呼び出し、実行ループを担当します。スタックは双方ともJava 25 / Spring Boot 4.1です。

ここで重要なのは、プロセスを分けた事実そのものではなく、分けた後に何をどちらに置くかでした。その決定が揺らぐと、プロセスだけが二つあり、責任は混在した状態になります。実際、その問題はすぐに起こりました。

2.2 制御の所有者を一つに — 二重制御の罠

アップロードのように実際に何かを変更するツールを作る中で、実行前に人が確認する承認カードを付けました。基準は「IDを入力させず、聞き返さず、画面より簡単に全体の流れを確認できるようにする」でした。

しかし最初の実装では、承認が必要なツールを呼び出すと、カードではなくエラー文字列が返ってきました。モデルはその文字列を受け取り、言葉で説明しながらユーザーに内部IDを聞き返しました。カードは表示されませんでした。

原因は二つありました。一つは承認待ちを例外として投げていたこと、もう一つはエージェント側にも承認をブロックするロジックがあり、MCPサーバーが提案を作る機会すらなかったことです。制御が二つの階層に重複していたため、外側の階層が内側の階層のより良い応答を横取りしたのです。

そこで、承認判断の所有者をMCPサーバー一か所に定め、エージェントは通過させるようにしました。そして承認待ちをエラーではなく、構造化された実行提案として正常に返すよう変更しました。

[承認待ちをエラーではなく提案として]

// A pending approval is not an error. Returning it as a structure keeps the
// decision with the person, instead of letting the model narrate a failure.
static String of(String approvalId, MethodSignature signature, Object[] args,
                 McpTool mcpTool, NaviToolPolicy policy, ObjectNode form) {

    ObjectNode proposal = OBJECT_MAPPER.createObjectNode();
    proposal.put("proposalType", "approval.required");
    proposal.put("approvalId", approvalId);
    proposal.put("tool", mcpTool.name());
    proposal.put("risk", policy.risk());

    // 'arguments' keeps the original values for re-execution after approval.
    // 'fields' carries only what a person needs to read on the card.
    ObjectNode arguments = proposal.putObject("arguments");
    ArrayNode fields = proposal.putArray("fields");
    for (int i = 0; i < parameters.length && i < args.length; i++) {
        arguments.putPOJO(names[i], args[i]);
        if (isHidden(names[i])) continue;          // internal ids stay out of sight
        fields.addObject().put("label", label(parameters[i], names[i]))
                          .putPOJO("value", args[i]);
    }
    return proposal.toString();
}

argumentsとfieldsを分けたことが、この設計の核心です。実行に必要な元の引数はargumentsにそのまま保持し、承認後の再実行に使います。カードには、人が確認する値だけを表示します。内部IDのようにユーザーが知る必要のないキーは隠します。

ただし、引数を隠すと新たな問題が生じました。アップロードツールの引数がすべて内部IDだったため、隠すと空のカードになってしまいました。そこでツールに表示専用のパラメータを追加しました。モデルがユーザーに分かる名前を入力し、カードにはその値だけが表示されます。

権限制御も同じ原則にしました。ツールポリシーに必要なロールを宣言し、ツール一覧を返す際に呼び出し元の権限でフィルタリングします。そして誰かがMCPを直接呼び出して一覧取得の段階を迂回しても、実行直前に同じ検査をもう一度行います。

[一覧で一度、実行直前にもう一度]

// The list is filtered by the caller's grants, but the check is repeated here:
// a client that bypasses tools/list must not bypass the policy.
if (StringUtils.hasText(toolPolicy.requiredRole())) {
    grant = grantResolver.resolveGrant(
            toolPolicy.executionLocation(), toolPolicy.requiredRole());
    if (grant == null) {
        String reason = "required role not granted: "
                + toolPolicy.requiredRole() + "@" + toolPolicy.executionLocation();
        audit(context, mcpTool.name(), riskLevel, false, reason);
        return denied(reason);
    }
}

2.3 定義は、それを知っている側が所有する

承認カードが動作し始めた後も、同じ原則を適用できていない箇所が一つ残っていました。カードで値を編集するときに使う編集フィールドの一覧が、フロントエンドにハードコードされていたのです。

この構造の問題は、失敗が静かなことです。ツールがフィールドを追加したり名前を変更したりしても、フロントエンドが知らなければ、そのフィールドだけ編集できなくなります。エラーが発生しないため、誰かが見つけるまでそのまま残ります。

原因は、フォーム定義を描画する側が所有していたことでした。実際にどのフィールドが存在するかを知っているのはツールの所有者なのに、契約が逆になっていました。そこで、MCPサーバーがツールと型の組み合わせごとにフォームを作り、承認提案に一緒に含めて送信するようにし、フロントエンドは形だけを検証して描画するよう変更しました。食い違いがある場合は読み取り専用になります。

結果として、フロントエンドのフォームスキーマファイルは190行から31行になり、フィールドが変わった場合はサーバーだけを修正すればよい構造になりました。

3. 試行錯誤から得た教訓

第一に、制御を二つの階層に重複して置くと、内側の階層が作れるより良い応答を外側の階層が横取りします。承認カードが表示されなかった問題が、まさにそれでした。制御の所有者を一か所に定め、残りは委譲するという決定を先にすべきでした。「二か所でブロックすれば、より安全ではないか」という直感は、ブロックするだけなら正しいですが、ブロックの代わりにより良いものを提示すべき場合には誤りです。

第二に、定義は描画する側ではなく、知っている側が所有すべきです。フロントエンドがフォームスキーマを持っている構造を最初に見たとき、疑うべきでした。承認制御をサーバーに集約した決定と同じ原則なのに、フォームには適用し忘れていました。同じ原則を立てていても、適用範囲を狭く捉えると、その隙間で同じ種類の失敗が再生産されます。

第三に、エラーとして返すものと正常な応答として返すものを区別する必要があります。承認待ちは失敗ではなく、人の決定を待っている状態です。それを例外として投げた瞬間に構造が失われ、文字列だけが残り、モデルがその文字列を解釈して言葉で説明することになります。人がボタンで決めるべきことをモデルが文章で説明しているなら、多くの場合、応答形式が間違っています。

第四に、「機能を有効にした」ことと「機能が動作している」ことは異なります。この時期、native tool callingを有効にしていましたが、実際には毎回別の経路で動作していました。設定ファイルのマップキーにコロンが含まれていたため、バインディングが誤ったキーになり、取得結果が空だったため、静かにフォールバック経路を通っていました。エラーは一行もありませんでした。フォールバックがあるシステムでは失敗が静かなため、有効にしたと信じている経路が実際に使われているかをログで確認する習慣が必要です。

第五に、デプロイ単位を分ける議論では、トランザクションとデータベースの境界を最初に見るべきです。後になって、「別のMCPプロセスが既存サービスを依存関係として持ち、直接呼び出す」という案を検討したことがありました。しかし、そのサービスの参照層がトランザクションと永続化に結び付いていたため、実質的にはそのサービスをもう一度起動する構図になっていました。図としてはもっともらしく見えましたが、コードを一か所開いてみるだけで1分もかからず崩れる案でした。

4. 適用結果

分離の決定と制御階層の設計による結果は、次のように整理できます。

項目

統合した場合

分離した結果

MCPの役割

自分自身へのJSON-RPC — プロトコルを使う理由が消滅

プロセス境界を越える実際の通信

制御

ホストとサーバーが同じ場所 — 再検証が無意味

ホストが通過させても、サーバーが最終判断

再利用

特定ホスト専用

複数のMCPクライアントが同じサーバーを共有

承認応答

例外 → 文字列 → モデルがIDを聞き返す

構造化された提案 → フロントエンドがカードとしてレンダリング

フォーム定義

フロントエンドが複製(190行、ひそかな失敗)

ツール所有者が提供(31行)

実運用での検証では、「upload-testをGlobal Galleryハブにアップロードして」と一文で指示するだけで、listConnectedHubs → listCatalogKollexes → uploadKollexToHubが自動的に連鎖しました。承認カードには「Kollex: upload-test / アップロード先のハブ: Global Gallery」のように、ユーザーが把握できる名前だけが表示され、内部IDの漏洩は0件でした。

副次的に、応答サイズの問題もこの時期に解決しました。検索応答にアイコン画像が丸ごと含まれていたため、ツールの応答が1.1MBに達していましたが、サーバーが応答から該当フィールドを再帰的に削除し、約5,900文字まで縮小しました。フロントエンドに送る元の応答はそのままにし、モデルに渡す観測情報だけを減らしたのです。

5. 制限と今後の計画

第一に、この時点での承認はカードの表示まででした。承認ボタンを押した際の実際の処理、つまり承認識別子を発行して検証し、元の呼び出しを続けて実行する部分は未実装で、カードには「承認機能は準備中」と正直に表示していました。この部分は後に、MCP標準のelicitationを利用して、サーバーが直接人に尋ねる方式で完成させました。

第二に、ゲートウェイ機能として明示していた呼び出し量の制限が未実装で、監査ログもアプリケーションログとしてしか残っていませんでした。統制の所有者を決めることと、その統制を完全に整備することは別の作業です。

第三に、ここで作成した統制層は、その後さらに二度揺らぎました。権限検査を精緻化した後、その大部分を再び取り除き、承認の所有者も最終的にはさらに下位の層へ移しました。作成したものを削除する判断がどのような根拠で行われたのかは、別途整理する予定です。

今回の決定を通じて確認できたのは、プロジェクトをいくつに分けるかという問いが、実際には責任をどこに置くかという問いだったということです。プロセスを分けること自体は簡単です。難しいのは、分けた後も各層が自分の責任だけを担う状態を維持することであり、この記事で扱った三つの試行錯誤は、いずれもその境界が一度ずつずれた事例でした。

参考文献

Junny

Site footer