0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code × Docker | ローカルMCPサーバーをHTTP化してコンテナ配布するまで

0
Posted at

「stdioで動くMCPサーバーは作れたけど、チームメンバーの端末やCIからも同じサーバーを叩きたい」——この感覚は正しいです。stdioトランスポートはプロセスをローカルに1個ずつ起動する前提なので、共有したい瞬間に詰まります。

この記事では、既存のstdio実装をStreamable HTTPトランスポートに載せ替え、Dockerでコンテナ化し、Claude Code CLIから--transport httpで登録するところまでを、実際に動くコードで追っていきます。

なぜstdioのままだと共有できないのか

stdioトランスポートは「Claude CodeがサブプロセスとしてMCPサーバーを起動し、標準入出力でJSON-RPCをやり取りする」方式です。1人の開発機で完結する分には手軽ですが、次のような要求には応えられません。

  • 別マシン(CI、他のメンバーの端末)から同じツールを呼びたい
  • 認証を挟んでアクセス制御したい
  • サーバー側の状態(RAGインデックス、DBコネクションプール等)をプロセス間で共有したい

2025年3月のMCP仕様改定で、リモート向けの標準トランスポートとしてStreamable HTTPが追加されました。Claude Code CLI側もこれに対応していて、claude mcp add --transport http <name> <url>で登録できます。ここではこの経路を実際に組み立てます。

Step 1: stdio実装をStreamable HTTPへ載せ替える

@modelcontextprotocol/sdkMcpServer自体はトランスポートに依存しません。stdio版で使っていたserver.tool(...)の登録コードはそのままに、接続部分だけStreamableHTTPServerTransportに差し替えます。

// src/server.ts
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

function buildServer() {
  const server = new McpServer({ name: "hub-seo", version: "1.0.0" });

  // stdio版から移植したツール定義。ここは変更不要
  server.tool(
    "expand_keywords",
    "Googleサジェストからキーワード候補を展開する",
    { seed: z.string() },
    async ({ seed }) => ({
      content: [{ type: "text", text: `expanded: ${seed} ...` }],
    }),
  );

  return server;
}

const app = express();
app.use(express.json());

// セッションごとにtransportを保持する(状態を持つツールがある場合に必要)
const transports = new Map<string, StreamableHTTPServerTransport>();

app.post("/mcp", async (req, res) => {
  const sessionId = req.header("mcp-session-id");
  let transport = sessionId ? transports.get(sessionId) : undefined;

  if (!transport) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => transports.set(id, transport!),
    });
    const server = buildServer();
    await server.connect(transport);
  }

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

app.listen(3800, () => console.log("MCP server listening on :3800"));

sessionIdGeneratorundefinedにすればステートレス(リクエストごとに使い捨て)にもできます。ツールが外部APIを叩くだけで内部状態を持たないなら、まずはステートレスから始めるのが安全です。セッション管理のバグを増やさずに済みます。

Step 2: Dockerでコンテナ化する

stdioのままだとホストのファイルシステムやnpx実行環境に依存しがちですが、HTTP化するとコンテナに閉じ込めやすくなります。

FROM node:24-slim AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3800
CMD ["node", "dist/server.js"]
docker build -t hub-mcp-seo:1.0.0 .
docker run -d --name hub-mcp-seo -p 127.0.0.1:3800:3800 hub-mcp-seo:1.0.0

ここで意図的に-p 127.0.0.1:3800:3800とホスト側バインドを絞っています。-p 3800:3800とだけ書くと全インターフェースに公開され、同一LAN上の別端末から素通しでツールが叩けてしまいます。実運用では後述のトークン認証とセットで、まず公開範囲を絞るのが先です。

Step 3: Claude Code CLIへHTTPサーバーとして登録する

claude mcp add --transport http hub-seo http://127.0.0.1:3800/mcp

登録後はclaude mcp listhub-seoconnectedになっているか確認します。stdio版と違い、Claude Code側でプロセスを起動し直す必要がないので、サーバーを再デプロイしてもclaude mcp addをやり直す必要はありません。

疎通確認だけならcurlでも叩けます。

curl -i -X POST http://127.0.0.1:3800/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}'

レスポンスヘッダのmcp-session-idが発行されていれば、以降のリクエストにこのIDをMcp-Session-Idヘッダとして付けることで同一セッションを継続できます。

Step 4: 認証ヘッダで無認証運用を避ける

HTTP化した時点で「誰でも叩ける」状態になりがちです。claude mcp add--headerでリクエストヘッダを追加できるので、Bearerトークンを1枚挟みます。

claude mcp add --transport http hub-seo http://127.0.0.1:3800/mcp \
  --header "Authorization: Bearer ${HUB_MCP_TOKEN}"

サーバー側は素直にミドルウェアで検証します。

app.use("/mcp", (req, res, next) => {
  const auth = req.header("authorization");
  if (auth !== `Bearer ${process.env.HUB_MCP_TOKEN}`) {
    res.status(401).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: "unauthorized" },
      id: null,
    });
    return;
  }
  next();
});

トークンは.envに置き、リポジトリにはコミットしません。docker runに渡す場合も--env-file .env経由にして、docker inspectでホスト側の誰かがコマンド履歴から値を見られる状態を避けます。認証を後回しにしたままDockerで公開ポートを開けると、-p 3800:3800の一行だけで社内ネットワークの誰からでもツールが呼べる、という事故につながります。

まとめ

stdio実装をStreamable HTTPに載せ替える作業自体は、McpServerのツール定義を変えずに接続部分だけ差し替えるので大きくありません。実際に手を動かすと詰まるのはむしろ運用面で、「セッションをどう持つか」「公開範囲をどう絞るか」「認証をどこで挟むか」の3点です。

まずステートレスで動かして、状態が必要になったタイミングでセッション管理を足す。公開ポートは127.0.0.1縛りから始めて、必要な範囲だけ開ける。この順番を守るだけで、共有可能なMCPサーバーへの移行はそれほど怖くありません。

0
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?