Findyの爆速開発を支える生成AI活用 ~リモートMCPサーバー公開編~

こんにちは。

ファインディ株式会社 で Tech Lead をやらせてもらってる戸田です。

現在のソフトウェア開発の世界は、生成AIの登場により大きな転換点を迎えています。

GitHub Copilotやチャットベースの開発支援ツールなど、生成AIを活用した開発支援ツールが次々と登場し、開発者の日常的なワークフローに組み込まれつつあります。

そのような状況の中で、このたび弊社からリモートMCPサーバーを公開することとなりました。

そこで今回は、リモートMCPサーバーを公開する上で実際に考慮したポイントを紹介します。

それでは見ていきましょう!

公開したリモートMCPサーバーの概要

MCPに関しては、前回の記事で詳しく説明していますので、公式ドキュメントと合わせてそちらをご覧ください。

tech.findy.co.jp

modelcontextprotocol.io

今回公開したリモートMCPサーバーを使うことで、GitHub CopilotやDevinの利用状況を取得して可視化することが可能になります。詳細はリリースブログをご覧ください。

https://findy.co.jp/2843/findy.co.jp

なお、今回公開したリモートMCPサーバーのご利用は、2025/05/19現在、ファインディ株式会社のクライアント企業様限定となっております。

ご利用希望の方は、申込みフォームからお申し込みください。

docs.google.com

インフラ構成

今回、弊社が公開したリモートMCPサーバーのインフラ構成は次のようになっています。

リモートMCPサーバーのインフラ構成図

開発当初はAWS Lambdaを使うことを検討していたのですが、次の理由から採用を見送り、現状のシンプルな構成に落ち着きました。

  • 実装内容をLambdaに合わせる必要がある
  • 後述するSSE Transportを使う場合、クライアント、サーバー間のコネクションを維持する必要があり、その要件がLambdaと合わない

今回はdeploy時にDocker imageをECRにPushして、ECSを使ってサーバーを起動します。ログ出力はCloudWatchを使い、VPC経由でクライアント側からアクセス出来るようになっており、比較的よく見かけるシンプルな構成になっているかと思います。

今回のリモートMCPサーバーは、ファインディのSREチームが取り組んでいるセキュリティを担保したインフラ環境構築の一環として、HCP TerraformのPrivate Registryを活用した初の本番環境構築事例となりました。

このPrivate Registryに登録したモジュールは、Trivyによるセキュリティスキャンでエラーが出ない状態を担保しています。

一方で、全社的に必須とされている数多くの設定の中には、リモートMCPサーバーの特性上、一部適用が難しいものもありました。

特に、一部のWAFルールがリモートMCPサーバーの通信をブロックしてしまうケースが存在しました。

そのため、インフラ構築を進めながらPrivate Registryに登録したモジュールのカスタマイズも同時に行い、リモートMCPサーバーの要件を満たすインフラ環境を実現しました。

この取り組みにより、ファインディではより迅速かつセキュアにサービス提供ができる基盤が整ったと言えます。

内部実装

リモートMCPサーバーの内部実装の方法は、後述する認証情報の取得とTransportの利用が異なるだけで、基本的にローカルで実行するものと同じです。

具体的なコードを書くと、次のようになります。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express, { Request, Response } from "express";
import { z } from "zod";

const getMcpServer = (name: string) => {
  const mcpServer = new McpServer({
    name: 'sample-mcp-server',
    version: '1.0.0',
  }, { capabilities: { logging: {} } });

  mcpServer.tool(
    "addition",
    "足し算をする",
    { a: z.number(), b: z.number() },
    async ({ a, b }) => ({
      content: [
        { type: "text", text: `Hello ${name}` },
        { type: "text", text: String(a + b) }
      ]
    })
  );

  return mcpServer;
};

async function main() {
  try {
    const app = express();

    const transports: Record<string, SSEServerTransport> = {};
    app.get("/sse", async (req: Request, res: Response) => {
      const transport = new SSEServerTransport("/message", res);
      const sessionId = transport.sessionId;
      transports[sessionId] = transport;

      const name = req.get("X-Hoge-Name");
      const { server } = getMcpServer(name);
      await server.connect(transport);

      server.onclose = async () => {
        await server.close();
        process.exit(0);
      };

      transport.onclose = () => {
        delete transports[sessionId];
      };
    });

    app.post("/message", async (req: Request, res: Response) => {
      const sessionId = req.query.sessionId as string | undefined;
      if (!sessionId) {
        res.status(400).send('Missing sessionId parameter');
        return;
      }

      const transport = transports[sessionId];
      if (!transport) {
        res.status(404).send('Session not found');
        return;
      }

      await transport.handlePostMessage(req, res, req.body);
    });

    const PORT = process.env.PORT || 3001;
    app.listen(PORT, () => {
      console.log(`Server is running on port ${PORT}`);
    });

    process.on('SIGINT', async () => {
      for (const sessionId in transports) {
        try {
          await transports[sessionId].close();
          delete transports[sessionId];
        } catch (error) {
          console.error(`Error closing transport for session ${sessionId}:`, error);
        }
      }
      process.exit(0);
    });
  } catch (error) {
    console.error('Error starting server:', error);
    process.exit(1);
  }
}

main().catch(error => {
  console.error('Unhandled error:', error);
  process.exit(1);
});

SSE TransportでステートフルなMCPサーバーを実装する場合のサンプルコードとなります。

MCPサーバー自体の実装はローカル環境で実行するものと変わりませんが、MCPサーバーをExpressなどのサーバーに接続して起動している点が異なります。

つまるところ、サーバーに接続する箇所以外は基本的に同じ実装で良いので、サーバーの起動部分とMCPサーバーの実装部分を分けて実装しておくと、ローカルで実行するケースとリモートで実行するケースの両方に対応できるようになります。

また、SSE Transportを使って通信を行う場合、MCPクライアントとのセッション管理を行う必要があります。MCPサーバーと接続、切断した時や、サーバーそのものを停止した際にセッションに対する各種操作を実行する必要があります。

認証情報

MCPサーバーからAPIを実行する際にアクセストークンが必要なケースがあります。

ローカル環境でMCPサーバーを実行する場合は環境変数を使ってアクセストークン等を渡すのが一般的です。

しかし、リモートMCPサーバーの場合は環境変数に個人のアクセストークンを設定することは出来ません。リモートMCPサーバーの場合、動作する場所がローカル環境ではなく共通のサーバーになるからです。

こういったケースの場合、環境変数ではなくheaders経由でアクセストークンを渡すことが出来ます。

{
  "servers": {
    "sample-mcp": {
      "url": "https://example.com/sse",
      "type": "sse",
      "headers": {
        "X-Hoge-Name": "Fuga",
        "X-Access-Token": "<YOUR_ACCESS_TOKEN>"
      }
    }
  }
}

このようにMCPクライアント側の設定ファイルを記述することで、MCPサーバーと接続したタイミングにheaders経由でリモートMCPサーバーが必要な情報を受け取ることが出来るようになります。

Streamable HTTP Transport

これまでのリモートMCPサーバーとの通信方式はSSE Transportを使うのが一般的でした。

しかし、PROTOCOL VERSION 2025-03-26からSSE Transportはdeprecatedになり、代わりにStreamable HTTP Transportを使うことが推奨されるようになりました。

modelcontextprotocol.io

Streamable HTTP Transportに置き換わることで、次のようなメリットがあります。

  • ステートレスなMCPサーバーを実装できる
  • プレーンなHTTPサーバーとして実装できる
  • MCPクライアントとサーバーのコネクションを維持し続ける必要がなくなる

2025/05/19現在ですと、MCPクライアント側のStreamable HTTP Transportへの対応が出来ていないケースがあるため、しばらくはSSE Transportで通信できるようにする必要がありそうです。

弊社としましても、公開直後はSSE Transportでの提供を行い、折を見てStreamable HTTP Transportに移行していく予定です。

MCPサーバー側のTransport関連の実装は、公式SDKにサンプルコードがあるので、そちらを参考にしてみると良いでしょう。

github.com

まとめ

いかがでしたでしょうか?今回の記事がリモートMCPサーバーを公開する上での参考になれば幸いです。

今回公開したリモートMCPサーバーはα版という位置付けであり、今後も機能追加や改善を行っていく予定です。

2025/05/19現在、ファインディ株式会社のクライアント企業様限定の公開とはなっていますが、ご利用希望の方は是非、申込みフォームからお申し込みください。

docs.google.com

現在、ファインディでは一緒に働くメンバーを募集中です。

興味がある方はこちらから ↓ herp.careers