1
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サーバーをリモート化してチームで共有する(Streamable HTTP / Docker / Cloud Run)

1
Posted at

blastengine MCPサーバーは、各自のPCで起動するstdio方式のサーバーです。個人で使う分にはこれで十分ですが、チームで使い始めると困ることが出てきます。

  • メンバー全員にblastengineのAPIキーを配ることになる
  • 送信系のフラグを各自の設定で自由に変えられる
  • 誰がいつ何を実行したかが、各自のPCにしか残らない

本記事では、blastengine MCPサーバーのフォークにStreamable HTTPのエントリポイントを追加し、1か所に置いたサーバーをチームで共有する構成にします。APIキーと送信系のフラグはサーバー側だけが持ち、メンバーには個別のアクセストークンを配ります。

stdio版の導入は導入記事、フォークの改造の作法は独自ツールを追加する記事を参照してください。手順はblastengine MCP v0.2.2(MCP TypeScript SDK 1.30.0)とClaude Code 2.1.284で、実際のblastengineアカウントに接続して確認しています。

構成

[メンバーAのClaude Code] ─┐  Authorization: Bearer <Aのトークン>
[メンバーBのClaude Code] ─┼──────────────────────────────▶ [blastengine MCP (HTTP)] ──▶ blastengine API
[バッチ(claude -p)]    ─┘                                   ・APIキーを保持
                                                              ・送信系フラグを固定
                                                              ・誰が何を呼んだかを記録
項目 stdio(各自のPC) HTTP(共有サーバー)
APIキーの置き場所 各メンバーの環境変数 サーバーだけ
送信系フラグ 各自が設定 サーバーの起動時に固定
メンバーの認証 なし(APIキーを持つ人が使える) メンバーごとのトークン
実行の記録 各自のPC サーバーのログに集約
ローカルファイルを扱うツール 使える サーバー側のファイルを見る(後述)

前提:これはフォークの改造です

公式のサーバーはstdioだけに対応しています。エントリポイントのsrc/index.tsは、StdioServerTransportにつないで起動するだけの短いファイルです。

src/index.ts(抜粋)
const config = loadConfig();
const server = createBlastengineMcpServer({ config });
const transport = new StdioServerTransport();
await server.connect(transport);

ツールの定義はcreateBlastengineMcpServer()(src/server.ts)にまとまっていて、トランスポートとは切り離されています。つまり、このファイルと並べてHTTP用のエントリポイントを1つ足せば、ツールの実装には手を入れずにHTTP化できます。

git clone https://github.com/<あなたのアカウント>/blastengine-mcp.git
cd blastengine-mcp
git checkout -b remote-http
npm install

MCP TypeScript SDKには、Node.jsのhttpモジュールで使えるStreamableHTTPServerTransportが含まれています。追加の依存パッケージは要りません。

手順1:HTTPのエントリポイントを追加する

src/http.tsを新しく作ります。

src/http.ts
#!/usr/bin/env node
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createHash, timingSafeEqual } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { loadConfig } from "./config.js";
import { createBlastengineMcpServer } from "./server.js";

// MCP_ACCESS_TOKENS="alice:トークン,bob:トークン"の形式で、メンバーごとにトークンを配る
function loadAccessTokens(raw: string | undefined): Map<string, Buffer> {
  const tokens = new Map<string, Buffer>();
  for (const entry of (raw ?? "").split(",")) {
    const [name, token] = entry.trim().split(":");
    if (name && token) {
      tokens.set(name, digest(token));
    }
  }
  if (tokens.size === 0) {
    throw new Error("MCP_ACCESS_TOKENS is required (e.g. alice:xxxx,bob:yyyy)");
  }
  return tokens;
}

function digest(value: string): Buffer {
  return createHash("sha256").update(value, "utf8").digest();
}

function authenticate(req: IncomingMessage, tokens: Map<string, Buffer>): string | undefined {
  const header = req.headers.authorization ?? "";
  if (!header.startsWith("Bearer ")) {
    return undefined;
  }
  const presented = digest(header.slice("Bearer ".length));
  for (const [name, expected] of tokens) {
    if (timingSafeEqual(presented, expected)) {
      return name;
    }
  }
  return undefined;
}

async function readJson(req: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > 1024 * 1024) {
      throw new Error("request body too large");
    }
    chunks.push(chunk as Buffer);
  }
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
}

// 誰がどのツールを呼んだかだけを残す。引数(宛先・本文)は残さない
function auditLog(member: string, body: unknown): void {
  const messages = Array.isArray(body) ? body : [body];
  for (const message of messages) {
    const m = message as { method?: string; params?: { name?: string } };
    if (m?.method === "tools/call") {
      process.stderr.write(`[audit] member=${member} tool=${m.params?.name ?? "?"}\n`);
    }
  }
}

const config = loadConfig();
const tokens = loadAccessTokens(process.env.MCP_ACCESS_TOKENS);
const port = Number.parseInt(process.env.PORT ?? "8080", 10);

const httpServer = createServer(async (req, res) => {
  const path = (req.url ?? "/").split("?")[0];

  if (path === "/health") {
    sendJson(res, 200, { ok: true });
    return;
  }
  if (path !== "/mcp") {
    sendJson(res, 404, { error: "not_found" });
    return;
  }

  const member = authenticate(req, tokens);
  if (!member) {
    res.setHeader("WWW-Authenticate", "Bearer");
    sendJson(res, 401, { error: "unauthorized" });
    return;
  }

  // ステートレス運用:GET(サーバーからの通知ストリーム)とDELETE(セッション終了)は受けない
  if (req.method !== "POST") {
    res.setHeader("Allow", "POST");
    sendJson(res, 405, { jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null });
    return;
  }

  try {
    const body = await readJson(req);
    auditLog(member, body);

    // リクエストごとにサーバーとトランスポートを作って捨てる(ステートレス)
    const server = createBlastengineMcpServer({ config });
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
      enableJsonResponse: true
    });
    res.on("close", () => {
      void transport.close();
      void server.close();
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, body);
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : String(error);
    process.stderr.write(`[error] ${message}\n`);
    if (!res.headersSent) {
      sendJson(res, 400, { jsonrpc: "2.0", error: { code: -32700, message: "Bad request" }, id: null });
    }
  }
});

httpServer.listen(port, () => {
  process.stderr.write(
    `blastengine MCP server listening on :${port}/mcp ` +
      `(send=${config.enableSend} bulk=${config.enableBulk} csv=${config.enableCsvImport})\n`
  );
});

ステートレスにする

MCPのStreamable HTTPには、サーバーがセッションIDを発行して接続ごとに状態を持つ「ステートフル」と、状態を持たない「ステートレス」の2つの動かし方があります。sessionIdGenerator: undefinedを指定するとステートレスになります。

blastengine MCPのツールは、どれも1回の呼び出しで完結します。呼び出しをまたいで覚えておく情報がないので、ステートレスで十分です。リクエストのたびにサーバーとトランスポートを作って捨てるため、複数のインスタンスに負荷を分散しても問題が起きません。Cloud Runのように、インスタンスの数が勝手に増減する環境と相性がよい作りです。

ステートレスではサーバーからクライアントへの通知の流れ(GET)を使わないので、POST以外は405で断っています。enableJsonResponse: trueは、応答をSSEのストリームではなく通常のJSONで返す指定です。

メンバーごとのトークンで認証する

MCP_ACCESS_TOKENSに名前:トークンの組をカンマ区切りで渡します。リクエストのAuthorization: Bearerヘッダーを照合して、どのメンバーのトークンかを特定します。

トークンの比較にはtimingSafeEqualを使っています。通常の文字列比較は一致しない文字が見つかった時点で終わるため、応答時間の差からトークンを推測される余地があります。timingSafeEqualは長さの同じバッファしか比べられないので、両方をSHA-256に通して32バイトにそろえています。

メンバーが抜けたら、そのメンバーのトークンだけを外して再起動します。blastengineのAPIキーを作り直す必要はありません。

誰が何を呼んだかを残す

auditLog()で、tools/callのたびにメンバー名とツール名を標準エラーに出しています。引数は出しません。宛先や本文は個人情報を含むためです。blastengine MCP本体もトークン・本文・宛先をログに出さない設計なので、それに合わせています。

手順2:ビルドしてDockerイメージにする

npm run build

dist/http.jsができていれば成功です。package.jsonのbuildスクリプトはsrc配下をまとめてコンパイルするので、設定の変更は要りません。

続いてDockerfileを作ります。

Dockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
COPY scripts ./scripts
COPY src ./src
RUN npm ci && npm run build && npm prune --omit=dev

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 8080
CMD ["node", "dist/http.js"]

サーバーの要件はNode.js 22.15以上なので、node:22-slimを使います(検証時は22.23.3が入りました)。.dockerignoreにnode_modules、dist、.git、.envを書いておきます。

docker build -t blastengine-mcp-remote .

手順3:起動して確かめる

メンバーのトークンを作り、APIキーと一緒に環境変数で渡して起動します。送信系のフラグは渡していないので、既定のまま無効です。

TOKEN_ALICE=$(openssl rand -hex 24)
TOKEN_BOB=$(openssl rand -hex 24)

docker run -d --name be-mcp -p 127.0.0.1:18080:8080 \
  -e BLASTENGINE_LOGIN_ID="$BLASTENGINE_LOGIN_ID" \
  -e BLASTENGINE_API_KEY="$BLASTENGINE_API_KEY" \
  -e MCP_ACCESS_TOKENS="alice:$TOKEN_ALICE,bob:$TOKEN_BOB" \
  blastengine-mcp-remote

起動ログに、送信系フラグの状態が出ます。

blastengine MCP server listening on :8080/mcp (send=false bulk=false csv=false)

curlで認証まわりを確認します。

curl -s localhost:18080/health
# {"ok":true}

curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:18080/mcp -d '{}'
# 401(トークンなし)

curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:18080/mcp \
  -H 'Authorization: Bearer xxx' -d '{}'
# 401(トークン違い)

curl -s -o /dev/null -w "%{http_code}\n" localhost:18080/mcp \
  -H "Authorization: Bearer $TOKEN_ALICE"
# 405(GETは受けない)

正しいトークンでツールを呼ぶと、blastengineの結果が返ります。

curl -s -X POST localhost:18080/mcp \
  -H "Authorization: Bearer $TOKEN_ALICE" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blastengine_usage_latest_get","arguments":{}}}'
{"result":{"content":[{"type":"text","text":"..."}],"structuredContent":{"month":202609,"current":0,"remaining":10000,"updated_time":"2026-09-30T01:46:42+09:00","plan_id":"be-trial-10000"}},"jsonrpc":"2.0","id":1}

Acceptヘッダーにはapplication/jsonとtext/event-streamの両方が必要です。どちらかが欠けると、SDKが次のエラーを返します。

{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}

送信系ツールを呼ぶと、サーバー側のフラグで止まります。

{
  "code": "send_disabled",
  "message": "BLASTENGINE_ENABLE_SEND=true is required to send transaction mail",
  "retryable": false
}

メンバーが自分の設定をどう書き換えても、サーバーが送信を有効にしていない限り送れません。stdio版との一番大きな違いはここです。

手順4:Claude Codeから接続する

メンバーは、自分のトークンを環境変数に入れておきます。

export BLASTENGINE_MCP_TOKEN="配られたトークン"

プロジェクトで共有するなら、.mcp.jsonに書きます。headersの値も${VAR}で環境変数から展開できるので、トークンをファイルに直書きせずに済みます。

.mcp.json
{
  "mcpServers": {
    "blastengine": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${BLASTENGINE_MCP_TOKEN}"
      }
    }
  }
}

コマンドで登録する場合は--transport httpと--headerを使います。

claude mcp add --transport http blastengine https://mcp.example.com/mcp \
  --header "Authorization: Bearer $BLASTENGINE_MCP_TOKEN"

この場合、シェルが展開したトークンがそのまま設定ファイル(~/.claude.json)に保存されます。claude mcp get blastengineでもトークンが表示されます。共有する設定には.mcp.jsonの${VAR}形式を使ってください。

プロジェクトの.mcp.jsonに書いたサーバーは、claude mcp listで「Pending approval (run claude to approve)」と表示され、対話モードで承認するよう案内されます。なお、検証では非対話モード(claude -p)の場合、承認前でもこのサーバーが読み込まれてツールを呼べました。

サーバー名をblastengineにしておけば、ツール名はstdio版と同じmcp__blastengine__blastengine_...になります。--allowedToolsの指定やhooksのmatcherなど、stdio版で作った設定をそのまま使えます。

メンバーBのトークンで、Claude Codeから次のように聞いてみました。

blastengineの今月の利用状況と、今日(2026-09-30 JST)の配信ログを宛先ごとに教えて。
今日(2026-09-30 JST)の配信ログ(宛先ごと)

| 宛先 | 配信種別 | ステータス | 配信時刻 | 応答 |
|---|---|---|---|---|
| test@test123.live | TRANSACTION | SENT(成功) | 01:58:31 | 250「配信に成功しました」 |

サーバーのログには、メンバー名とツール名が残ります。

[audit] member=alice tool=blastengine_usage_latest_get
[audit] member=bob tool=blastengine_usage_latest_get
[audit] member=bob tool=blastengine_mail_results_list

最初の行は、手順3でcurlから呼んだ分です。

ローカルファイルを扱うツールに注意

リモート化すると、動きが変わるツールが2つあります。どちらも、引数でファイルのパスを受け取るツールです。

ツール stdio版 リモート版
bulk_import_recipients_csv 手元のCSVを読む サーバー上のパスとして読む
bulk_import_error_download 手元にzipを保存する サーバー上に保存する

BLASTENGINE_ENABLE_BULK=trueとBLASTENGINE_ENABLE_CSV_IMPORT=trueを付けたサーバーを、別のコンテナ(be-mcp-csv)として起動して試しました。メンバーのPC上のパスを渡すと、次のエラーになります。

{
  "code": "invalid_csv_path",
  "message": "csv_path does not exist or is not readable",
  "retryable": false
}

エラーCSVのダウンロードは、もっと気づきにくい動きをします。呼び出しは成功して保存先とバイト数が返りますが、ファイルはサーバー(コンテナ)の中に書かれています。

{
  "output_path": "/tmp/err54.zip",
  "bytes_written": 335,
  "note": "Error CSV zip was saved to output_path. File contents are not returned by MCP."
}
docker exec be-mcp-csv ls -la /tmp/err54.zip
# -rw-r--r-- 1 node node 335 ... /tmp/err54.zip

メンバーの手元には何も残りません。Cloud Runのようにコンテナのファイルが一時的な環境では、インスタンスが入れ替わると消えます。

共有サーバーではBLASTENGINE_ENABLE_CSV_IMPORTを有効にしないでください。大量の宛先を扱う一斉配信は、CSVを置けるstdio版の環境で行うほうが素直です。エラーCSVのダウンロードはフラグとは関係なく使えるため、気になる場合はHTTP版でだけこのツールを登録しないよう、フォーク側でserver.tsを分けてください。

本番に置く:Cloud Runの例

チームから届く場所に置く例として、Cloud Runの手順を示します。以下は、新規に近いGCPプロジェクトで実際に通した手順です。

PROJECT=your-project-id
PROJECT_NUMBER=$(gcloud projects describe $PROJECT --format="value(projectNumber)")
SA=${PROJECT_NUMBER}-compute@developer.gserviceaccount.com

# 使うAPIを有効にする
gcloud services enable run.googleapis.com cloudbuild.googleapis.com \
  secretmanager.googleapis.com artifactregistry.googleapis.com --project $PROJECT

APIキーとトークンは、Secret Managerに入れて環境変数として渡します。値はコマンドライン引数に書かず、標準入力から渡します。

printf '%s' "$BLASTENGINE_LOGIN_ID" | gcloud secrets create blastengine-login-id --data-file=- --project $PROJECT
printf '%s' "$BLASTENGINE_API_KEY" | gcloud secrets create blastengine-api-key --data-file=- --project $PROJECT
printf '%s' "alice:$TOKEN_ALICE,bob:$TOKEN_BOB" | gcloud secrets create blastengine-mcp-tokens --data-file=- --project $PROJECT

# Cloud Runの実行サービスアカウントにシークレットの読み取りを許可する
for s in blastengine-login-id blastengine-api-key blastengine-mcp-tokens; do
  gcloud secrets add-iam-policy-binding $s --project $PROJECT \
    --member="serviceAccount:$SA" --role=roles/secretmanager.secretAccessor
done

--sourceでデプロイすると、Cloud Buildがソースからイメージを作ります。新しいプロジェクトでは、ビルドに使う既定のサービスアカウントに権限が足りず、次のエラーで失敗しました。

ERROR: (gcloud.run.deploy) PERMISSION_DENIED: Build failed because the default service account
is missing required IAM permissions. ...
IAM permission denied for service account 285173491298-compute@developer.gserviceaccount.com.

このサービスアカウントにroles/run.builderを付けると通ります。

gcloud projects add-iam-policy-binding $PROJECT \
  --member="serviceAccount:$SA" --role=roles/run.builder

あとはデプロイするだけです。Dockerfileがそのまま使われます。

gcloud run deploy blastengine-mcp \
  --source . \
  --region asia-northeast1 \
  --no-invoker-iam-check \
  --set-secrets "BLASTENGINE_LOGIN_ID=blastengine-login-id:latest,BLASTENGINE_API_KEY=blastengine-api-key:latest,MCP_ACCESS_TOKENS=blastengine-mcp-tokens:latest" \
  --project $PROJECT

--no-invoker-iam-checkは、Cloud RunのIAMによる呼び出し元チェックを外す指定です。Claude CodeからはGoogleのIDトークンを付けずに接続するため、認証はアプリ側のトークンに任せます。実際に、トークンなしとトークン違いのリクエストは、アプリが401で断りました。

デプロイ後に表示されるURLの末尾に/mcpを付けたものが、.mcp.jsonのurlになります。

.mcp.json
{
  "mcpServers": {
    "blastengine": {
      "type": "http",
      "url": "https://blastengine-mcp-xxxxxxxxxxxx.asia-northeast1.run.app/mcp",
      "headers": {
        "Authorization": "Bearer ${BLASTENGINE_MCP_TOKEN}"
      }
    }
  }
}

この設定でClaude Codeから当日の配信ログを問い合わせると、ローカルのDockerと同じ結果が返りました。監査ログはCloud Loggingに入ります。

gcloud logging read \
  'resource.type="cloud_run_revision" AND resource.labels.service_name="blastengine-mcp" AND textPayload:"[audit]"' \
  --project $PROJECT --limit 5 --format="value(timestamp,textPayload)"
2026-09-29T18:14:17.871950Z	[audit] member=bob tool=blastengine_mail_results_list
2026-09-29T18:12:45.722248Z	[audit] member=alice tool=blastengine_send_transaction
2026-09-29T18:12:45.427732Z	[audit] member=alice tool=blastengine_usage_latest_get

2行目は、送信系を無効にしたサーバーにcurlで送信を試みた記録です。呼び出しはsend_disabledで拒否されていますが、試みたこと自体は残ります。

ヘルスチェックのパスは/healthzではなく/healthにしています。最初は/healthzで作っていましたが、Cloud Runでは末尾がzの一部のパスが予約されていて、リクエストがアプリに届かずGoogleの404ページが返りました。

送信を有効にするなら

ここまでの構成は、送信系をすべて無効にした参照専用のサーバーです。チームで配信状況を確認したり、不達の問い合わせを調べたりする用途なら、これで足ります。

送信も共有サーバーから行う場合は、参照専用とは別のサーバーを立てて、送信を任せるメンバーにだけそのトークンを配るのが安全です。1つのサーバーに全員が接続する構成では、送信系を有効にした時点で全員が送れるようになります。トークン単位でツールを制限する仕組みは、この記事の実装にはありません。

また、Claude Codeのhooksによる誤送信ガードはクライアント側の仕組みです。ツール名はstdio版と同じなので同じhooksが動きます。実際に、リモート版に接続した状態でテスト宛先以外への送信を指示すると、hooksが止めて、サーバーのログにも呼び出しは残りませんでした。ただし、hooksを入れていないクライアントからの呼び出しは止められません。送信のルールを全員に強制したいなら、検査をサーバー側(フォークのoperations.ts)に入れる必要があります。

つまずきやすいポイント

症状 原因と対処
401が返る Authorization: Bearer <トークン>の形か確認する。.mcp.jsonの${VAR}が展開されているか(環境変数をexport済みか)も確認する
curlで呼ぶと受け付けられない Accept: application/json, text/event-streamの両方を付ける
Cloud Runで/healthzが404になる Cloud Runは末尾がzの一部のパスを予約しており、アプリに届かない。/healthなど別の名前にする
/mcp以外のパスで接続できない このサーバーは/mcpだけを受ける。URLの末尾を確認する
CSV取り込みでinvalid_csv_path パスはサーバー側のファイルとして解決される。共有サーバーではCSV取り込みを使わない
エラーCSVが手元に無い サーバー(コンテナ)の中に保存されている。共有サーバーでは使わない
使用量が送信数に追いつかない 使用量の集計には反映のラグがある。updated_timeで集計時刻を確認する
メンバーを外したい MCP_ACCESS_TOKENSからそのメンバーの組を消して再起動する。APIキーの再発行は不要

まとめ

blastengine MCPサーバーをチームで共有するポイントは3つです。

  • ツールの定義はトランスポートから切り離されているので、HTTPのエントリポイントを1つ足すだけでリモート化できる。ツールが1回で完結するので、ステートレスで動かせる
  • APIキーと送信系フラグはサーバーだけが持ち、メンバーには個別のトークンを配る。誰が何を呼んだかはサーバーのログに集約する
  • ファイルのパスを受け取るツールは、サーバー側のファイルを見るようになる。共有サーバーでは使わない

個人のPCで試す段階から組織で使う段階に進むときは、「誰が送れるのか」「誰が何をしたのか」をサーバー側で決められる構成にしておくと、運用のルールを設定ファイルの約束事に頼らずに済みます。


blastengine MCPサーバーへの要望や感想は、以下のアンケートフォームから送ると開発チームに届くみたいです。

https://docs.google.com/forms/d/e/1FAIpQLSdNZ9TswUT3JEv3pMHkJGqOvgmsXuKP1smkJiNEsqbTVDwRyg/viewform

1
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
1
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?