0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数のMCPサーバーを「1個に見せる」集約ゲートウェイを作ってみた話

0
Posted at

複数のMCPサーバーを「1個に見せる」集約ゲートウェイを作ってみた話
はじめに
以前、claude.ai(Web版)・GrokからもLUNAを操作できるようにした話 — OAuthプロキシを添えてやESP32上で動くMCP(Model Context Protocol)サーバーという記事を書きました。今回はその続編……というより、MCPを複数作っている人なら誰でもぶつかるであろう問題の話です。LUNA(私が作っている望遠鏡コントローラー)の話は最小限にして、汎用的な技術ネタとして書きます。

何に困っていたか
MCPサーバーを1個だけ作っている間は快適です。困るのは「機能ごとにサーバーを分けて開発している」場合。私のケースだと:

星図を動かすMCPサーバー(10ツール)
撮影機材(NINA)を操作するMCPサーバー(6ツール)
カメラ監視用のMCPサーバー
……と今後も増える予定
これらを個別のMCPサーバーとして開発すること自体は良いのですが、claude.ai側から使おうとすると「カスタムコネクタ」をサーバーの数だけ登録する必要があるのが辛いところでした。OAuth認証も個別、PINも個別、ユーザーから見ると「似たような接続設定を何回もやらされる」体験になります。

解決の方向性: 集約ゲートウェイ
やりたいことはシンプルで、AIから見て「1個のMCPサーバー、ツールがたくさんある」ように見せることです。

claude.ai ──(1本のMCP接続)──> 集約ゲートウェイ ─┬─> 星図サーバー(10ツール)
├─> 撮影サーバー(6ツール)
└─> ...(今後追加)
ゲートウェイ自身も1個のMCPサーバーとして振る舞いつつ、内部では複数のバックエンドMCPサーバーへtools/callを振り分ける、という単純な仕組みです。「MCPプロキシ」「MCPゲートウェイ」と呼ばれるパターンで、探せば同種の実装は他にもあります。

実装のキモ: SDKを両側から使う
最初、「JSON-RPCを自前でパースして、セッション管理も自前で……」と身構えていたのですが、実際には公式@modelcontextprotocol/sdkをクライアント側・サーバー側の両方で使い回すだけで済みました。

バックエンドへの接続(クライアント側):

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "gateway-to-starchart", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL("http://127.0.0.1:4600/mcp"));
await client.connect(transport); // initialize / notifications/initialized はSDKが内部で処理
const { tools } = await client.listTools();
ゲートウェイ自身をMCPサーバーとして公開する側(サーバー側)は、便利ラッパーのMcpServerではなく、あえて低レベルのServerクラスを使いました:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: cache.toolList, // バックエンドから集めたツール一覧をそのまま返す
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
const target = lookup.get(req.params.name); // prefix付きツール名から行き先を逆引き
const client = await getBackendClient(target.backend);
return client.callTool({ name: target.realName, arguments: req.params.arguments });
});
理由は、バックエンドのtools/listで返ってくるinputSchemaが素のJSON Schemaであって、McpServer.registerTool()が期待するzodのRawShapeではなかったからです。低レベルAPIならバックエンドのスキーマをそのまま素通しできて、変換の手間もバグの元も無くなります。

ハマりどころ
ツール名の衝突は本当に起きる。星図サーバーと撮影サーバー、両方にget_state/get_system_info/get_license_infoが実在していて、そのまま合体させると衝突しました。starchart_get_stateのようにバックエンドごとにprefixを付けて解決。
tools/listをバックエンドの生死に依存させない。起動時に一度キャッシュしてしまい、実行中にバックエンドが落ちてもゲートウェイのtools/list自体は正常に返す設計にしました。片方のサーバーを意図的に止めてテストしたところ、生きている方のツールだけ正しく列挙され、落ちている方はdisabled扱いになって全体は落ちない、という挙動を確認できました。
tools/callの障害も1個ずつ隔離。落ちているバックエンド宛のツール呼び出しだけがisError: trueで失敗し、他のツールは正常に動く。AIが「今どのツールが使えないか」を正しく認識できます。
実機での検証結果
手順 結果
バックエンド2台への接続・tools/list取得 ✅ 16ツール(10+6)取得、期待値と完全一致
prefix付与後の衝突チェック ✅ 衝突なし
片方のバックエンドを停止した状態でのtools/list ✅ 生きている方だけ正しく列挙、クラッシュなし
片方のバックエンドを停止した状態でのtools/call ✅ 対象ツールだけisError、他は正常
PIN認証 → Bearerトークン取得 → ゲートウェイ経由tools/call ✅ discovery→DCR→PIN同意→token→401確認→initialize→notifications/initialized→tools/list→tools/callの9ステップ、全てクリア
最後の行は、claude.ai/Grokが実際に行うのと同じOAuth 2.1 + PKCEのフルフローです。既存のOAuthプロキシ(前々回の記事の実装)にBearer検証だけ残し、その先をこのゲートウェイに繋ぎ替える形で、認証まわりを作り直さずに済みました。

これから
現状はまだプロトタイプ段階(2台のバックエンドで検証済み)ですが、登録リストとしては将来分も含めて枠を用意していて、望遠鏡本体(ESP32)を含む全部で10個のバックエンドを1つのゲートウェイにぶら下げる設計まで固めました。望遠鏡本体だけはユーザーごとにIPアドレスが違うので、そこだけ実行時に設定できるようにしてあります。

おわりに
「MCPサーバーを機能ごとに分割して作る」というやり方自体は開発のしやすさの面で理にかなっていますが、そのままだと利用者側の接続設定がサーバーの数だけ増えていきます。集約ゲートウェイは、開発側の分割統治とユーザー体験のシンプルさを両立させる、わりと素直な解決策でした。同じような構成で複数のMCPサーバーを運用している方の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?