MCP Gateway — AIエージェントのツール呼び出しを誰がコントロールしているのか

MCP Gateway — AIエージェントのツール呼び出しを誰がコントロールしているのか

MCPが月間9,700万ダウンロードを突破し事実上の標準になったが、エージェントがどのツールをどれだけ呼び出すかを制御するレイヤーは欠けている。MCP Gatewayパターンでこの課題を解決する。

私のClaude Codeセッション一つが、7つのMCPサーバーに接続されている。GitHub、Notion、Google Calendar、Gmail、Chrome DevTools、NotebookLM、そしてTelegram。このエージェントは私のメールを読み、カレンダーに予定を作り、Notionページを編集し、Chromeタブを開くことができる。

で、これを誰が監視しているのか?

誰もいない。少なくとも今の私のローカル環境では。

MCPは成功した。セキュリティレイヤーはまだだ

MCP(Model Context Protocol)の成長は凄まじい。Python + TypeScript SDKの合計月間ダウンロード数が9,700万を超え、Anthropic、OpenAI、Google、Microsoft、Amazonがすべてサポートしている。2024年末にAnthropicが作り、2025年12月にLinux FoundationのAAIFに寄付されて以来、事実上「AIエージェントが外部ツールを呼び出す方法」の標準となった。

問題は、このプロトコルが接続にフォーカスしていて、制御にはあまり関心がないということだ。

MCPサーバーを作るとツール(tool)を定義し、クライアントがそのツールを呼び出す。認証?OAuth 2.1がスペックに入った。しかし「このエージェントがこのツールを1日に何回まで呼び出せるか」「機密データを返すツールは承認なしで呼び出してはならない」といったポリシーレイヤーはMCPプロトコル自体にはない。それは実装側の責任だ。

そこで出てきた概念がMCP Gatewayだ。

MCP Gatewayとは何か

API Gatewayを想像すればいい。KongやAWS API Gatewayのようにバックエンドの前にプロキシを置くように、MCPサーバー群の前にプロキシを一つ置く。

エージェント → MCP Gateway → MCPサーバー群

Gatewayがやること:

  • 認証/認可:どのエージェントがどのツールにアクセスできるか
  • レートリミット:ツール呼び出し頻度の制限
  • 監査ログ:誰がいつどのツールを呼んだか全て記録
  • ポリシー適用:特定のツールは人間の承認後にのみ実行
  • トラフィックルーティング:リクエストを適切なMCPサーバーに転送

私はこれをローカル環境で簡単にテストしてみた。Node.jsでMCPプロキシを一つ作り、Claude Codeと実際のMCPサーバーの間に挟む方式だ。

// 最もシンプルなMCP Gatewayの骨格
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";

const gateway = new Server({ name: "mcp-gateway", version: "0.1.0" }, {
  capabilities: { tools: {} }
});

// ポリシーエンジン — ここで呼び出しを許可/拒否する
const policy = {
  "gmail_read_message": { rateLimit: 10, requireApproval: false },
  "gmail_create_draft": { rateLimit: 5, requireApproval: true },
  "gcal_delete_event": { rateLimit: 2, requireApproval: true },
  "notion-update-page": { rateLimit: 20, requireApproval: false },
};

const callCount: Record<string, number> = {};

gateway.setRequestHandler(CallToolRequestSchema, async (request) => {
  const toolName = request.params.name;
  const rule = policy[toolName];
  
  // レートリミットチェック
  callCount[toolName] = (callCount[toolName] || 0) + 1;
  if (rule && callCount[toolName] > rule.rateLimit) {
    return {
      content: [{ type: "text", text: `Rate limit exceeded for ${toolName}` }],
      isError: true,
    };
  }
  
  // 承認が必要なツールはブロック
  if (rule?.requireApproval) {
    console.error(`[GATEWAY] Approval required for: ${toolName}`);
    // 実際にはここでSlack/Telegramに承認リクエストを送る
  }
  
  // 監査ログ
  console.error(`[AUDIT] ${new Date().toISOString()} | ${toolName} | args: ${JSON.stringify(request.params.arguments)}`);
  
  // 実際のMCPサーバーにフォワード(ここでは省略)
  return await forwardToUpstream(toolName, request.params.arguments);
});

このコードが実際にプロダクションで使えるか?正直まだだ。しかしコアアイデアはこれだけで十分伝わる。エージェントのツール呼び出しは必ず一箇所を経由すべきで、その一箇所でポリシーを適用できなければならない。

実際に動かしてみたら足りないものが見えた

上のコードをClaude Codeに挟んで動かしてみた。結論から言うと——そのままでは動かない。

最初の問題はツールリストの同期だ。GatewayがCallToolRequestをインターセプトするには、まずクライアント(Claude Code)に「これらのツールがある」と伝える必要がある。上のコードにはlistToolsハンドラーがない。upstream MCPサーバーに接続してツールリストを取得し、それをそのままクライアントに伝えるパートを自分で作る必要がある。

import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

gateway.setRequestHandler(ListToolsRequestSchema, async () => {
  const upstreamTools = await fetchToolsFromUpstream();
  return { tools: upstreamTools };
});

これだけで動くには動くが、upstreamサーバーが複数あるとツール名が衝突する可能性がある。私の環境ではGmailとGoogle Calendarが両方ともlistのようなgenericなツール名を公開していたので、ネームスペースを付ける必要があった。

2つ目の問題はレートリミットの寿命だ。上のコードのcallCountはメモリ上にある。プロセスを再起動するとカウントが0に戻る。Claude CodeはセッションごとにMCPサーバーを新しく起動するので、セッションが変わるたびにリミットがリセットされる。「1日10回」というポリシーをちゃんと守れないということだ。

3つ目に、requireApprovalconsole.errorで出力するのは全く意味がなかった。stderrログをリアルタイムで見ている人がいないから。実際に承認を得るには外部チャネル(Telegram、Slack)にリクエストを送って応答が来るまでブロックする必要があるが、stdio基盤のMCPで非同期待機を実装するのはかなり面倒だ。

この3つを一度に解決する方法は何かと考えたが、少なくとも1つ目と2つ目の答えはシンプルだ。監査ログをファイルやメモリではなくSQLiteに書けばいい。

監査ログをSQLiteへ

console.errorで流していた監査ログをSQLiteテーブルに保存すれば、レートリミットの寿命問題も同時に解決する。プロセスが再起動してもDBは残るから。

import Database from "better-sqlite3";

const db = new Database("mcp-audit.db");

db.exec(`
  CREATE TABLE IF NOT EXISTS audit_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT DEFAULT (datetime('now')),
    tool_name TEXT NOT NULL,
    args TEXT,
    result_status TEXT DEFAULT 'ok',
    latency_ms INTEGER,
    blocked INTEGER DEFAULT 0,
    block_reason TEXT
  )
`);

const insertLog = db.prepare(`
  INSERT INTO audit_log (tool_name, args, result_status, latency_ms, blocked, block_reason)
  VALUES (?, ?, ?, ?, ?, ?)
`);

const countToday = db.prepare(`
  SELECT COUNT(*) as cnt FROM audit_log
  WHERE tool_name = ? AND timestamp > datetime('now', '-1 day') AND blocked = 0
`);

既存のcallCountディクショナリの代わりにcountTodayクエリを使えば、セッションが変わっても「今日Gmailの読み取りを何回呼んだか」を正確に追跡できる。Gatewayのハンドラーはこう変わる:

gateway.setRequestHandler(CallToolRequestSchema, async (request) => {
  const toolName = request.params.name;
  const rule = policy[toolName];
  const start = Date.now();

  if (rule) {
    const { cnt } = countToday.get(toolName) as { cnt: number };
    if (cnt >= rule.rateLimit) {
      insertLog.run(toolName,
        JSON.stringify(request.params.arguments),
        "blocked", 0, 1, "rate_limit");
      return {
        content: [{ type: "text",
          text: `Rate limit exceeded: ${toolName} (${cnt}/${rule.rateLimit} today)` }],
        isError: true,
      };
    }
  }

  const result = await forwardToUpstream(
    toolName, request.params.arguments);
  const latency = Date.now() - start;

  insertLog.run(toolName,
    JSON.stringify(request.params.arguments),
    "ok", latency, 0, null);
  return result;
});

溜まったログで何ができるか

数日動かすとmcp-audit.dbにデータが溜まった。これで出来ることが思った以上に多い。

ツール別呼び出し頻度——どのツールが最も多く呼ばれているか一目で分かる。

SELECT tool_name, COUNT(*) as calls, ROUND(AVG(latency_ms)) as avg_ms
FROM audit_log WHERE blocked = 0
GROUP BY tool_name ORDER BY calls DESC LIMIT 10;

私の場合notion-searchが圧倒的1位だった。エージェントが何かをする前にまずNotionを検索するパターンがあるようだ。これを見てNotion検索結果のキャッシングが意味あるかもと思った。

ブロック率——レートリミットに引っかかった呼び出しが全体の何パーセントか。

SELECT tool_name,
  SUM(CASE WHEN blocked = 1 THEN 1 ELSE 0 END) as blocked,
  COUNT(*) as total,
  ROUND(100.0 * SUM(blocked) / COUNT(*), 1) as block_rate
FROM audit_log GROUP BY tool_name HAVING blocked > 0;

ブロック率が高ければ2つのうちどちらかだ。リミットが厳しすぎるか、エージェントが同じツールを繰り返し呼ぶ非効率なパターンを持っているか。後者ならプロンプトを見直すべきだ。

時間帯別パターン——エージェントが最も活発な時間帯はいつか。

SELECT strftime('%H', timestamp) as hour, COUNT(*) as calls
FROM audit_log GROUP BY hour ORDER BY hour;

当然の結果ではあるが、私のcronジョブが走る11時〜12時に呼び出しが集中する。チーム環境ならこのデータでMCPサーバーの負荷分散タイミングを決められるだろう。

このデータの本当の価値はポリシーチューニングの根拠になるということだ。「Gmailの読み取りは1日10回で足りるか?」という問いに勘で答えるのではなく、実際の利用パターンを見て判断できる。私の場合gmail_read_messageは1日平均3回しか使っておらず、リミット10は余裕だった。一方notion-searchは1日40回近く呼ばれていてリミット20では足りず、30に上げた。

実際に必要になる瞬間

「うちのチームはまだMCPをそんなに使ってないですけど」——この言い訳が通じる時代が終わりつつある。

私が実際に経験したケースを一つ挙げると、Claude CodeでNotion MCPを使ってページを編集中に、意図せず別チームのページに触れてしまったことがある。エージェントが検索結果から似たタイトルのページを選び、私は承認ボタンを何も考えずに押した。データが消えたわけではないが、気まずかった。

これが1人の開発者のローカルで起きれば気まずい程度だ。しかし50人のチームがエージェントを使い、各エージェントが5〜10個のMCPサーバーに接続されていたら?監査ログもなしに?誰がどのツールを呼んだか追跡もできないなら?

エンタープライズでMCP Gatewayが必要な本当の理由はセキュリティよりも可視性だ。エージェントが何をしているのか見えなければならない。

すでに登場しているソリューション

MCP Gatewayという名前で登場しているオープンソースと商用プロジェクトがすでにある。調べた限りでは大きく2つのアプローチがある。

1. プロキシ方式 — エージェントとMCPサーバーの間にリバースプロキシを置く。既存のAPI Gatewayとアーキテクチャが同じだ。設定がシンプルで既存インフラを再利用できるのがメリット。

2. サイドカー方式 — 各MCPサーバーにポリシーエンジンを付ける。サービスメッシュ(Istio、Linkerd)のサイドカーパターンと同一だ。より細かい制御が可能だが運用の複雑さが上がる。

小規模チームならプロキシ方式で十分だと私は思う。サイドカーまで行くのはMCPサーバーが20個以上あり、チームごとに異なるポリシーが必要な場合だが、その規模なら既に専任のプラットフォームエンジニアがいるはずだ。

ただし、これは過渡期の解法だ

ここで批判的に考えるべきことがある。

MCP Gatewayが必要だということは、MCPプロトコル自体にガバナンスレイヤーが欠けているということだ。HTTPの上にAPI Gatewayを載せるのはHTTPに認証がないからではなく、ビジネスロジックとトラフィック管理が必要だからだ。MCPも同様に、プロトコルレベルでポリシーを定義できる拡張が出る可能性が高い。

その時、今作ったGatewayがレガシーになる。

個人的には6ヶ月以内にMCPスペックにpolicy extensionのようなものが追加されると見ている。Linux Foundationに寄付された後、ガバナンス関連の議論が活発なのを見ると、方向性はすでに固まっているようだ。しかしその6ヶ月間ノーコントロールでエージェントを動かすのはリスキーなので、Gatewayはその間を埋めるブリッジソリューションだ。

もう一つ——Gatewayを導入するとエージェントのレスポンス速度が遅くなる。プロキシを一段経由するので当然だ。ローカルでテストしたところ、ツール呼び出し1回あたり50〜100msのオーバーヘッドが追加された。ほとんどの場合は体感できないが、LLMが1タスクでツールを20〜30回呼ぶパターンでは全体で1〜2秒追加され、ユーザー体験に影響しうる。

まだ解決していないこと

SQLiteでログを溜めてポリシーをチューニングするところまでは一人でもできる。しかしrequireApproval——人間の承認を得るパートはまだちゃんと実装できていない。

次に試すのはTelegramボット連携だ。requireApproval: trueのツール呼び出しが来たらTelegramで承認リクエストを送り、ユーザーが「OK」を押すまでGatewayがリクエストをホールドする方式。アイデアはシンプルだが、stdio基盤のMCPでこれを非同期処理するには構造を変える必要がある。今はリクエストが来たらすぐレスポンスを返す同期構造なので。

そして根本的に、これは個人開発者のローカル環境でしか意味のないレベルだ。チーム単位で使うにはGateway自体の認証、マルチテナンシー、ポリシー管理UIなどが必要で、そこまで来ると自作ではなくプロダクトを使うのが正解だ。

AIエージェントにツールを渡す時、「何ができるか」と同じくらい「何をさせないか」が重要だ。MCP Gatewayは後者のための最も現実的な出発点であり、SQLite一つ付けるだけで「自分のエージェントが何をしているか」が見え始める。そこからポリシーはデータで決められる。

他の言語で読む

この記事は役に立ちましたか?

より良いコンテンツを作成するための力になります。コーヒー一杯で応援してください。

著者について

jw

Kim Jangwook

AI/LLM専門フルスタック開発者

10年以上のWeb開発経験を活かし、AIエージェントシステム、LLMアプリケーション、自動化ソリューションを構築しています。Claude Code、MCP、RAGシステムの実践的な知見を共有します。

ブログリストへ