複数の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サーバーを運用している方の参考になれば幸いです。