1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2026年5月版 リモートMCP開発ガイド — ローカルMCPを越えてビジネスにする

1
Last updated at Posted at 2026-06-04

📝 本記事について:この記事は AI(Claude)と共同で執筆しています。構成案・調査・下書きを AI と壁打ちしながらまとめ、最終的な編集・事実確認は人間(筆者)が行っています。本記事の各仕様・SDK バージョン・サービス情報は 2026年5月18日時点の調査に基づきます。MCP の Authorization 仕様は 2025-03-26(初版)→ 2025-06-18(Resource Server 分類)→ 2025-11-25(RFC 9728 整合・CIMD 推奨化) と短期間に2回改訂されており、本番化前に必ず公式仕様の最新版で再確認してください。旧 HTTP+SSE トランスポートは 2025-03-26 仕様で deprecate された点と、RFC 9728 整合と Resource Server 分類は 2025-06-18 以降である点だけ補足しておきます──新規開発でリモート化するなら、SSE は選ばず Streamable HTTP 一択です。

はじめに

stdio で MCP サーバーを公開した翌日、Slack で連絡が来ました。

「これ、社内の他チームにも配りたいんだけど、全員に claude_desktop_config.json を編集してもらうの?」

stdio MCP は強力ですが、ローカルプロセスの世界に閉じています。「インストール」「アップデート」「課金」「アクセス制御」が全部ユーザー側の責任になり、配布のたびに30人分の手作業が発生します。さらに、ChatGPT Developer Mode や Claude.ai(Web)のように stdio に非対応のクライアントからは、mcp-remote ブリッジを噛ませない限り使ってもらえません。

リモートMCP化は、この壁を一気に超えるための変換です。サーバーは1か所、ユーザーはURLとログインだけで使い始められる。課金フックを挟めば「使えば使うほど価値が出るサブスク」が成立し、Cloudflare Workers のようなサーバーレス環境にデプロイすればインフラもほぼゼロ運用に近づきます。

ただし、リモート化は stdio とは別の脅威モデルを抱えます。プロセス境界=信頼境界という stdio の安心は崩れ、OAuth・aud 検証・CORS・DNS rebinding・Confused Deputy……と、Web セキュリティの全部入りが必要になります。前編 Part 4-4 で扱った Token Passthrough、後編 付録C のチェックリスト、そして OWASP MCP Top 10 の MCP02/MCP07 が、ここからは「他人事」ではなくなります。

この記事は、ローカルMCP(stdio)で完結していた開発者が、リモートMCP化してビジネスとして売る/配るまでに必要な設計判断と実装を一通り扱います。

この記事で扱うこと:

  • なぜリモートMCP化するのか、どう判断するか(Part 0)
  • Streamable HTTP の最小実装からマルチテナント本番設計まで(Part 1)
  • OAuth 2.1 + PKCE の実装(DCR・CIMD・PRM・aud 検証)(Part 2)
  • リモートMCP固有のセキュリティ(Confused Deputy・Rug Pull・CORS・レート制限)(Part 3)
  • マネタイズのフックポイント設計(課金実装は抽象化レイヤまで)(Part 4)
  • Cloudflare Workers での実装例と配布・運用(Part 5)

TL;DR(読む時間がない人へ)

  • stdio と Remote は別物:プロセス境界=信頼境界という stdio の前提は HTTP で崩れる。env 直書き / console.log 禁則 / 1プロセス1ユーザーがすべて読み替えになる
  • トランスポートは Streamable HTTP 一択:旧 HTTP+SSE は 2025-03-26 で deprecate、WebSocket は MCP 仕様の正式トランスポートに含まれない(HTTP の WAF / CDN / 認証ヘッダ等の既存スタックを流用するため)
  • MCPサーバー = OAuth 2.1 Resource Server:トークン発行は AS に任せ、aud / iss / 署名検証だけを担う。aud 検証を欠かすと Token Passthrough / Confused Deputy が即成立。クライアント識別はCIMD 推奨(2025-11-25 仕様で明記、ChatGPT も要求)
  • リモート固有の脅威 3 軸:(a) Rug Pull の影響範囲が全ユーザー即時になるため監査ログ+バージョン署名+ユーザー通知の3点セット (b) Confused Deputy / Token Passthrough は aud 検証で防ぐ (c) 無料枠 abuse 対策は user / client / IP / tool の多軸レート制限
  • マネタイズはフックポイント設計:OAuth スコープでプラン別ツール露出 → tools/list 動的出し分け → tools/call の計量 → 課金プロバイダは抽象化レイヤ越しに呼ぶ(Stripe / Paddle 差し替え可)
  • Cloudflare Workers が有力agents/mcp 公式パッケージで OAuth Provider・Durable Objects によるセッション分離まで揃う。本記事は Cloudflare を主軸(Vercel / Fly.io / 自前 Node でも Part 1〜4 はそのまま流用可)

ユースケース別クイックスタートパス

記事全体を読む時間がない場合は、目的に合ったパートから入ってください。

目的 読むべきパート
最速でローカルMCPをHTTPに載せたい Part 1-1 → Part 2-4(aud 検証)→ Part 3-3(CORS)→ Part 5-1
OAuth 2.1 を正しく実装したい Part 2-1 → Part 2-2 → Part 2-3 → Part 2-4 → Part 2-5
課金・プラン管理を追加したい Part 4-1 → Part 4-2 → Part 4-3 → Part 4-4
Cloudflare Workers にデプロイしたい Part 5-1(全体)→ Part 5-1-2(制約)→ Part 5-1-3(ローカル開発)
エンタープライズ向けに仕上げたい Part 2-6-3 → Part 2-6-5 → Part 5-4-3 → Part 5-4-5 → Part 5-4-9
本番リリース前のチェックに使いたい 付録A(移行チェックリスト)→ 付録B(クライアント向け)

Part 0: なぜリモートMCP化するのか

0-1: Local vs Remote 比較表

ローカルMCP(stdio)とリモートMCP(Streamable HTTP)は、表面的には「同じ MCP 仕様の別トランスポート」ですが、運用・脅威モデル・ビジネスモデルが根本的に違います。前編・後編で散らばっていた論点を1枚にまとめます。

📌 判断軸まとめ

観点 ローカルMCP(stdio) リモートMCP(Streamable HTTP)
プロセス境界 ユーザーPC内で起動。1プロセス=1ユーザー サーバー側で常駐。1プロセス=Nユーザー(マルチテナント)
信頼境界 プロセス境界=信頼境界。env 直書きで足りる 信頼境界はトークン単位。aud 検証が必須
データの所在 ユーザーPC内で完結。外部送信なし(プライバシー強) サーバー側を経由(ログ・キャッシュも含めて要設計)
レイテンシ プロセス間通信のみ。ネットワーク遅延ゼロ RTT+サーバー処理時間。地理的距離が効く
オフライン動作 可能(出張・機内・閉域網で動く) 不可(接続前提)
配布・更新 各ユーザーが個別にインストール/更新 サーバー側を1回更新すれば全員反映
認証・課金 クライアントの設定で APIキー直書き OAuth 2.1 で一元管理。サブスク・従量・エンタープライズ
アクセス制御 ユーザーPC内でOS権限に依存 スコープ・テナント・IP・レートで細かく制御可能
対応ホスト Claude Desktop / Code / Cursor / VS Code / Gemini CLI 全ホスト+Claude.ai(Web)/ ChatGPT
主な脅威 Rug Pull(バージョン固定で軽減)、ローカル権限昇格 Confused Deputy、Token Passthrough、CORS、DDoS、テナント越境
Rug Pull の影響範囲 @latest 利用ユーザーのみ・更新タイミング次第 全ユーザー即時。サーバー側書き換えで一斉発火
インフラコスト ユーザーPC(=ゼロ) サーバー+認可サーバー+DB(少なくとも月数千円〜)
収益化のしやすさ OSS/APIキー型が中心 サブスク・従量・エンタープライズが現実的に

選択ガイド:次のいずれかが当てはまればリモート化を検討します。

  • 複数ユーザーに配りたい(社内チーム、顧客)
  • stdio 非対応クライアント(Claude.ai Web / ChatGPT)から使わせたい
  • 課金したい/プラン分けしたい
  • サーバー側で集中アップデートしたい
  • ユーザーPCにツールチェーン(Node / Python / Docker)を入れさせたくない

逆に「自分の PC のファイルを操作する」「外部に出してはいけないデータを扱う」「オフラインで動く必要がある」なら、ローカルMCP(stdio)のままが正解です。

💡 「リモート化=必ず良い」ではない:プライバシー・レイテンシ・オフライン性は ローカルMCP の不可逆な強みです。SaaS 業界で「クラウドネイティブ vs オンプレ」の議論が30年続いているのと同じく、MCP でも Local / Remote はトレードオフであり、用途次第で逆転します。

0-2: mcp-remote ブリッジパターン

「stdio 非対応クライアントからリモートMCPを呼びたい」という需要は実は逆方向にもあります。stdio クライアントしか持っていないユーザーが、Web上のリモートMCPを使いたいケースです。

その橋渡しが mcp-remote パッケージです。stdio で起動して、内部で HTTP(Streamable HTTP)に変換します。

// claude_desktop_config.json(stdio クライアント)
// 注:実ファイルでは `//` コメントを削除してください(標準JSONはコメント非対応)。
//     以降の `jsonc` ブロックも同様です。
// バージョン指定方針:CVE-2025-6514(後述)の対象は 0.0.5〜0.1.15。
//                    `0.1.16` 以降が必須要件、執筆時点の最新は `0.1.38`。
{
  "mcpServers": {
    "my-remote-mcp": {
      "command": "npx",
      "args": ["-y", "mcp-remote@0.1.38", "https://mcp.example.com/mcp"]
    }
  }
}

💡 URL パスの選び方mcp-remotedefault で http-first(Streamable HTTP を先に試し、404 のとき SSE にフォールバック)です。サーバーが Streamable HTTP なら /mcp を渡すのが本記事の建付けと整合します。SSE 専用の旧サーバーに繋ぐときだけ /sse を指定(または --transport sse-only)してください。

位置づけ:mcp-remote は「ローカル⇄リモート」のハイブリッドパターンです。クライアント側から見れば stdio、サーバー側から見れば HTTPS で接続される普通のリモートMCP。サーバー作者として大事なのは、mcp-remote 経由のクライアントを排除しない設計をすることです。具体的には:

  • 認証フローを OAuth 2.1 のブラウザフロー前提で組む(mcp-remote はローカルブラウザを開いて認可コードを取得する)
  • セッションIDを Mcp-Session-Id ヘッダで返す(mcp-remote が透過的に維持する)
  • MCP-Protocol-Version ヘッダの厳格チェックは入れる(mcp-remote 経由でも付与される)

⚠️ CVE-2025-6514 のリマインドmcp-remote 0.0.5〜0.1.15 には OS コマンドインジェクション(CVSS 9.6 Critical)があり、最低でも 0.1.16 以降への固定が必須要件です。執筆時点(2026-05)の最新は 0.1.38。サーバー作者として README に「mcp-remote@0.1.16 以降(推奨は最新 0.1.38)を使ってください」と明記しておくと、ユーザーの被害を未然に減らせます(前編 4-7)。

0-3: ホスティング選択肢の概観

リモートMCPサーバーを「どこに置くか」は、開発体験・コスト・運用負荷に直結します。2026年5月時点の主要オプションを比較します。

選択肢 種別 特徴 向いているケース
Cloudflare Workers サーバーレス agents/mcp 公式パッケージ。OAuth Provider・KV・Durable Objects 統合 個人〜中小規模・グローバル配信・低コスト
Vercel / Fly.io サーバーレス / コンテナPaaS Next.js / 既存 Node 資産との親和性 フロント一体・既存 Node 資産の延伸
マネージドMCP(Composio / Zapier / Pipedream / Smithery 等) プラットフォーム 既製の SaaS 連携+OAuth 代行・ホスティング込み 統合先を素早く増やしたい・認証管理を外注したい
自前 VPS(AWS/GCP/Azure) IaaS フルコントロール・コンプライアンス対応 エンタープライズ・規制業種

💡 本記事は Cloudflare Workers を主軸に解説します。理由は3つあります:

  1. 公式パッケージ agents/mcp が MCP の OAuth Provider・セッション管理・SSE まで提供しており、現時点で最もプロダクションに近いボイラープレートを持つ
  2. コールドスタートが他サーバーレス比で極小(一桁ミリ秒)で、tools/list のレイテンシが UX を壊さない
  3. 無料枠が広く(10万リクエスト/日)、個人開発からエンタープライズまで同じスタックで伸ばせる

ただし、本記事の Part 1〜4 はホスティング非依存で書きます。Part 5 で初めて Cloudflare 固有の話に入るので、他プラットフォームを選ぶ場合も Part 1〜4 はそのまま読めます。


Part 1: Streamable HTTP の最小実装から本番設計まで

1-1: 最小サーバー(デモ用)

まず「最低限動く」コードを示します。ここでは認証も検証も省略しており、本番化前に必ず Part 2 以降を読んでから差し替えてください。

SDK 公式の session-aware パターンは「initialize で transport を作って Map に保存、以降のリクエストは Mcp-Session-Id で引き当てる」構造です(simpleStreamableHttp.ts より抜粋・要約)。

// ⚠️ デモ用:認証・aud検証・Origin/Host検証・CORS・レート制限すべて未実装
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import express, { Request, Response } from "express";
import { randomUUID } from "node:crypto";

const transports: Record<string, StreamableHTTPServerTransport> = {};

function createServer() {
  const server = new Server(
    { name: "remote-mcp-demo", version: "1.0.0" },
    { capabilities: { tools: { listChanged: true } } }
  );
  // (ツール登録は後編 Part 0 と同様)
  return server;
}

const app = express();

// ⚠️ webhook 受信ルートだけは raw body 必須(署名検証のため)→ Part 4-4 を参照。
//   express.json() を「グローバル」に当てるとパース後の object になり、署名検証が落ちる。
//   解決策は (a) webhook ルートを express.json() より前に raw 付きで登録する、
//   (b) 本記事のように API ルートだけ json() を当てる、の 2 通り。本記事では (b)。
app.post("/mcp", express.json(), async (req: Request, res: Response) => {
  const sessionId = req.header("Mcp-Session-Id");

  if (sessionId && transports[sessionId]) {
    // 既存セッション:transport を使い回す
    await transports[sessionId].handleRequest(req, res, req.body);
    return;
  }

  if (!sessionId && isInitializeRequest(req.body)) {
    // 新規セッション:transport を生成して Map に登録
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => { transports[id] = transport; },
    });
    transport.onclose = () => {
      if (transport.sessionId) delete transports[transport.sessionId];
    };
    await createServer().connect(transport);
    await transport.handleRequest(req, res, req.body);
    return;
  }

  res.status(400).json({
    jsonrpc: "2.0",
    error: { code: -32600, message: "Bad Request: missing session or non-initialize" },
    id: req.body?.id ?? null,
  });
});

// GET /mcp:server→client SSE ストリーム(Part 1-2 で詳述)
app.get("/mcp", express.json(), async (req: Request, res: Response) => {
  const sessionId = req.header("Mcp-Session-Id");
  if (!sessionId || !transports[sessionId]) {
    return res.status(400).json({
      jsonrpc: "2.0",
      error: { code: -32600, message: "Bad Request: missing or invalid Mcp-Session-Id" },
      id: null,
    });
  }
  await transports[sessionId].handleRequest(req, res);
});

// DELETE /mcp:明示的なセッション終了
app.delete("/mcp", express.json(), async (req: Request, res: Response) => {
  const sessionId = req.header("Mcp-Session-Id");
  if (!sessionId || !transports[sessionId]) {
    return res.status(400).json({
      jsonrpc: "2.0",
      error: { code: -32600, message: "Bad Request: missing or invalid Mcp-Session-Id" },
      id: null,
    });
  }
  await transports[sessionId].handleRequest(req, res);
});

app.listen(3000);

これで POST /mcp(JSON-RPC)/ GET /mcp(SSE ストリーム)/ DELETE /mcp(セッション終了)の Streamable HTTP 3 メソッドが揃います。このまま公開すると即座に侵害されることが Part 2〜3 で見ていくとわかります。

💡 MCP-Protocol-Version 検証は Part 1-3 のミドルウェアを各ルートに追加挿入する形にします。本節のコードでは省略していますが、本番ビルドでは必ず差し込んでください。

⚠️ リクエストごとに transport を新規作成するのは禁則:旧来の「app.all("/mcp", ...) の中で毎回 new StreamableHTTPServerTransport() する」パターンは、セッション状態を破壊し、SSE ストリームも維持できません。ステートフル運用なら必ず Map で再利用してください。

📌 JSON-RPC error レスポンスの id フィールドポリシー(本記事全体):

  • 本文がパース可能だが業務的に失敗(クォータ・認証・プラン期限切れ):id: req.body?.id ?? null でリクエスト ID を返す
  • 本文パース前・到達不能な拒否(Content-Length 超過・不正 Host・トランスポート層拒否):id: null
  • 判断基準:「クライアントがどのリクエストに対する応答か特定できる必要があるか」。特定できる文脈なら ID を返す、できないなら null。

1-2: Mcp-Session-Id とステートフル/ステートレス選択

Streamable HTTP は「セッションIDを発行するかどうか」をサーバー側で選べます。仕様では Mcp-Session-Idサーバー発行が任意(MAY) で、発行された場合のみクライアントは以降の各リクエストに付ける義務(MUST) が発生します。

ステートフル構成(セッションID発行)

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => crypto.randomUUID(),
  // セッション情報を Map / Redis / DB に保存
  onsessioninitialized: (sessionId) => {
    // sessionId をストアに登録
  },
});

ステートレス構成(セッションID無し)

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => undefined,  // ID 発行しない
});
観点 ステートフル ステートレス
適性 長時間処理、進捗通知、Elicitation 利用 シンプルなツール呼び出しのみ
スケール セッションごとにサーバー親和性が必要(sticky session or 共有ストア) 完全に水平スケール可能
コスト 状態ストアのコスト ほぼゼロ
失効処理 TTL管理が必要 不要

💡 サーバーレス環境(Cloudflare Workers / Vercel Edge)はステートレス前提で組むのが原則です。状態が要るなら KV / Durable Objects / 外部DB に切り出します。

⚠️ セッションIDの不透明化(MUST):MCP 仕様の Security Considerations では、Mcp-Session-Id推測不可能な不透明文字列で発行する必要があります。連番・タイムスタンプ・ユーザーIDの平文は厳禁で、最低 128 bit のエントロピーcrypto.randomUUID() の v4 UUID は 122 bit、crypto.randomBytes(16) の Base64URL なら 128 bit)を確保します。後編 付録C #6 と同じ要件です。

📌 Streamable HTTP の 3 メソッド

メソッド 用途 備考
POST /mcp クライアント→サーバーの JSON-RPC リクエスト レスポンスは application/json(単発)か text/event-stream(SSE)。サーバー側で選択可
GET /mcp サーバー→クライアントの SSE ストリーム tools/list_changednotifications/progress・サンプリング要求・Elicitation 要求などサーバー発信を流す
DELETE /mcp 明示的なセッション終了 Mcp-Session-Id ヘッダ必須。サーバーは transport を解放

💡 GET /mcp の SSE ストリームが「リモート固有の hardest part」:stdio では stdin/stdout の双方向で済んでいたサーバー発信通知(Progress / Elicitation / Sampling)が、HTTP では GET で開いた SSE ストリーム上に流す設計に変わります。POST レスポンスの SSE と GET ストリームは別チャネルで、混同するとデッドロックや通知欠落の原因になります。

🆕 Elicitation の HTTP 経路(2025-06-18 追加):サーバーからユーザーへ追加入力を求める Elicitation は、GET /mcp で開いた SSE ストリーム上の elicitation/create リクエストとして送られます。クライアントは UI(テキスト入力・選択肢)を出して結果を POST /mcp で返します。実装には GET ストリームの長時間維持(後述 Part 5-3 の keep-alive)が前提となるため、Elicitation を使う場合はステートフル必須です。

🆕 Sampling の HTTP 経路も同様:サーバーがクライアント経由で LLM 推論を要求する Sampling(sampling/createMessage)も、GET /mcp の SSE ストリーム上に流れるサーバー発信リクエストです。クライアント側は受信した推論パラメータで LLM を呼び、結果を POST /mcp のレスポンスとして返します。Elicitation と同じく ステートフル + 長時間 SSE 維持が前提です。ステートレス Workers でこれらを使いたい場合は、Durable Objects でセッション単位の persistent socket を維持する Cloudflare 公式パターンに従う必要があります。

1-3: MCP-Protocol-Version ヘッダ(2025-06-18 以降)

Streamable HTTP では、initialize 完了後のすべての後続HTTPリクエストにネゴシエーション済みのプロトコルバージョンを示す MCP-Protocol-Version: <version> ヘッダを付けることが クライアントに MUST で課されています。

検証はミドルウェア関数として定義し、Part 1-1 の各ルートに挿入します(app.all("/mcp", ...) で別ルート定義してしまうと、Part 1-1 のハンドラと二重登録になり、express.json() も二度評価されて副作用が出るため避けます)。

// サーバーがサポートする MCP プロトコル revision を先に宣言
const SUPPORTED_VERSIONS = ["2025-06-18", "2025-11-25"];

import type { RequestHandler } from "express";

const requireProtocolVersion: RequestHandler = (req, res, next) => {
  // initialize 自体はバージョンネゴシエーションが完了する前なのでスルー
  // GET リクエストは body が空のため isInitialize=false になるが、
  // initialize は POST 経由なので GET で届くことはなく実害はない。
  const isInitialize = req.body?.method === "initialize";
  if (isInitialize) return next();

  const protoVer = req.header("MCP-Protocol-Version");
  if (!protoVer || !SUPPORTED_VERSIONS.includes(protoVer)) {
    res.status(400).json({
      jsonrpc: "2.0",
      error: {
        code: -32600,
        message: "MCP-Protocol-Version header missing or unsupported",
      },
      id: req.body?.id ?? null,
    });
    return;
  }
  next();
};

Part 1-1 のルートに挿入する形

// express.json() の直後に requireProtocolVersion を挟む
app.post  ("/mcp", express.json(), requireProtocolVersion, handleMcpPost);
app.get   ("/mcp", express.json(), requireProtocolVersion, handleMcpGet);
app.delete("/mcp", express.json(), requireProtocolVersion, handleMcpDelete);

💡 ヘッダ未送信クライアントへの暫定救済:仕様上はヘッダ MUST だが、移行期は旧クライアントへの配慮として protoVer 未送信時に default を 2025-03-26 と解釈して 1 revision だけ警告ログ付きで通す運用も実装可能。本記事の SUPPORTED_VERSIONS にはあえて含めていないので、明示の判断として運用ポリシーを決めてください。

💡 複数 revision を並行サポートするのが現実解:MCP 仕様は短期間に複数回更新されており、クライアント側の対応状況にばらつきがあります。サーバー側は 2〜3 revision を並行サポートしておくと、クライアント更新を強制せずに済みます。

🆕 JSON-RPC バッチは 2025-06-18 で削除済み:旧 2025-03-26 仕様にあった JSON-RPC バッチリクエスト(配列形式)は 2025-06-18 で取り除かれました。本記事の SUPPORTED_VERSIONS には 2025-03-26 を含めていないため req.body は単発オブジェクトと仮定して問題ありません。古い revision を並行サポートする場合のみ Array.isArray(req.body) 分岐を追加してください。

1-4: マルチテナンシー設計

stdio では「1プロセス=1ユーザー」が前提でした。リモートMCPでは1プロセスがNユーザーを捌くため、ユーザーごとの状態をプロセス変数に置いてはいけない点が最大の落とし穴です。

stdio 流の悪い設計

// stdio では問題なかったが、HTTPでは全ユーザーで共有される
let currentUserToken: string | null = null;
let userPreferences: Record<string, any> = {};

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  // 別ユーザーのリクエスト中に上書きされる
  currentUserToken = req.params._meta?.token;
  // ...
});

リモート流の正しい設計

// ユーザー情報は「リクエストハンドラの引数」または「セッションストア」経由で取得
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
  const userToken = extra.authInfo?.token;          // 認証ミドルウェアが詰める
  const sessionId = extra.sessionId;                // SDK が提供
  const userPrefs = await prefsStore.get(userId);   // 外部ストアから取得
  // ...
});

📌 マルチテナント設計の3原則

  1. プロセス変数にユーザー状態を置かない:必ずリクエスト引数または外部ストア経由で取得
  2. ユーザーIDをログの主キーにlogger.child({ userId }) でリクエスト全体に紐付け
  3. テナント越境の単体テスト:ユーザーAのリクエスト直後にユーザーBで呼び出し、Aの状態が漏れないか自動検証

⚠️ process.env の扱い:stdio では process.env がユーザー固有のシークレット置き場でした(クライアントが起動時に注入)。リモートでは process.env はサーバー全体の共有設定であり、ユーザー固有のシークレットを入れる場所ではありません。ユーザーのトークンは OAuth で受け取り、DB の per-user テーブルに暗号化して保存します。

📌 テナント分離の URL 設計 2 方式

方式 URL 例 利点 欠点
単一エンドポイント + JWT claim https://mcp.example.com/mcp 実装単純・公式 Registry 登録も 1 URL テナント識別がコード内
テナント別パス https://mcp.example.com/{tenant}/mcp CDN / WAF / アクセスログがテナント単位で扱える URL ホワイトリスト・aud 検証の設計が複雑化

エンタープライズ顧客は後者を要求しがちです。後者を採用するなら、PRM の resource と aud 検証期待値をテナントごとに変える(resource: "https://mcp.example.com/{tenant}")形になります。本記事のコード例は前者を前提に書いています。

1-5: 永続化レイヤ(各種ストア)の選択指針

本記事の以降のコード例では tokenStore / sessionStore / prefsStore / usageStore / quotaStore / stateStore といったストアが登場します。これらは抽象インターフェースで、実体は以下のいずれかを選ぶことになります。

ストア 推奨実装 理由
sessionStore(短命・読み書き頻繁) Cloudflare KV / Durable Objects / Redis TTL機能必須・グローバル整合性は不要
tokenStore(暗号化必須・監査対象) RDB(Postgres)の per-user テーブル + AES-256-GCM トランザクション・暗号化キー管理・監査ログとの結合
prefsStore / usageStore / quotaStore RDB(Postgres) 集計クエリ・整合性
stateStore(OAuth state パラメータ) Cloudflare KV / Redis TTL 5分程度・1度きり消費

📌 暗号化キーの管理tokenStore で AES-256-GCM などを使う場合、鍵自体は KMS(Cloudflare Secrets / AWS KMS / Google Cloud KMS) で管理し、コードから鍵そのものを抜けないようにします。process.env.ENCRYPTION_KEY に平文で置くのは最低限の出発点で、本番運用では KMS 統合が必須です。

📌 ストア類の抽象インターフェース定義(以降のコードで利用する型を一箇所に集約)

// レコード型
type RefreshTokenRecord = {
  userId: string;
  chainId: string;     // ローテーションチェーン識別子
  usedAt?: number;     // 使用済みなら epoch ms
};

type StateData = {
  userId: string;
  redirectUri?: string;
  expiresAt: number;                 // ms(Date.now() 互換)
  userExplicitlyConsented?: boolean;
};

type SessionData = {
  id: string;
  userId: string;
  createdAt: number;
};

// ストアインターフェース
interface SessionStore {
  get(id: string): Promise<SessionData | null>;
  set(id: string, data: SessionData, opts?: { ttl?: number }): Promise<void>;
  delete(id: string): Promise<void>;
  findByUserId(userId: string): Promise<SessionData[]>;
}

interface TokenStore {
  // OAuth Refresh Token
  findRefreshToken(token: string): Promise<RefreshTokenRecord | null>;
  markUsed(token: string): Promise<void>;
  revokeChain(chainId: string): Promise<void>;
  // JWT 失効
  blacklistJti(jti: string, expEpoch: number): Promise<void>;
  // 下流 API トークン(GitHub / Slack 等)
  saveDownstream(userId: string, provider: string, tokens: unknown, opts?: { encryption?: string }): Promise<void>;
  getDownstreamToken(userId: string, provider: string): Promise<string | null>;
  // プラン更新(Webhook 経由)
  savePlan(userId: string, plan: string): Promise<void>;
  // グレースフルシャットダウン用
  flush(): Promise<void>;
}

interface JtiStore {
  exists(jti: string): Promise<boolean>;
  set(jti: string, value: unknown, opts: { ttl: number }): Promise<void>;
}

interface StateStore {
  // ★ 一度きり消費(CSRF + Confused Deputy 対策)
  consume(state: string): Promise<StateData | null>;
  put(state: string, data: StateData): Promise<void>;
}

interface PrefsStore {
  get(key: string): Promise<any>;
  set(key: string, value: unknown, opts?: { ttl?: number }): Promise<void>;
}

interface UsageStore {
  getCurrentPeriod(userId: string): Promise<{ calls: number }>;
  getAll(userId: string): Promise<unknown>;
}

interface QuotaStore {
  getForUser(userId: string): Promise<{ maxCalls: number }>;
}

interface EventStore {
  exists(eventId: string): Promise<boolean>;
  markProcessing(eventId: string): Promise<void>;
  markCompleted(eventId: string): Promise<void>;
}

interface AuditStore {
  append(entry: Record<string, unknown>): Promise<void>;
  findByUserId(userId: string, opts?: { limit?: number }): Promise<unknown[]>;
}

// 計量プロバイダ(Stripe Metered Billing 等の抽象化)
interface MeteringClient {
  record(input: {
    userId: string; tool: string; durationMs: number;
    success: boolean; cost?: number; error?: string;
  }): Promise<void>;
}

これらは抽象インターフェースなので、Cloudflare Workers なら KV / DO / R2 で、Node 自前デプロイなら Postgres / Redis で実装します。Part 1-5 の対応表に従って実装クラスを差し替えてください。

1-6: マルチテナント整合性のテスト

「ユーザーAのリクエスト直後にユーザーBで呼び出し、Aの状態が漏れないか」は、リモートMCP特有のリグレッション源です。最小の自動テスト例:

// test/multi-tenant.spec.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

test("user state must not leak across sessions", async () => {
  const userA = await connectAs("user-a-token");
  const userB = await connectAs("user-b-token");

  // userA に状態を書き込む
  await userA.callTool({ name: "set_pref", arguments: { key: "lang", value: "ja" } });

  // userB で同じ pref を取得 → 漏れていない(default が返る)こと
  const resB = await userB.callTool({ name: "get_pref", arguments: { key: "lang" } });
  expect(resB.content[0].text).not.toBe("ja");
  expect(resB.content[0].text).toBe("en");  // default
});

async function connectAs(token: string) {
  const transport = new StreamableHTTPClientTransport(
    new URL("http://localhost:3000/mcp"),
    { requestInit: { headers: { Authorization: `Bearer ${token}` } } }
  );
  const client = new Client({ name: "test", version: "0" }, {});
  await client.connect(transport);
  return client;
}

Part 2: OAuth 2.1 + PKCE の実装

2-1: 認可フロー全体図

MCP の OAuth は 2025-06-18 仕様で MCPサーバー=Resource Server(RS) として分類され、トークン発行は Authorization Server(AS) に分離されました(同居も別ホストも許容)。

全体フロー

重要ポイント

  • PKCE(S256)は MUST:認可コード盗用対策。code_challenge / code_verifier のペアを毎回生成
  • resource パラメータは MUST(RFC 8707):クライアントは認可・トークン要求時に「どの RS 宛のトークンか」を明示
  • aud 検証は MUST:RS 側は受信トークンが自分宛か検証(Part 2-4

2-2: クライアント登録3方式の選択基準

MCP 仕様はクライアント登録に3方式を許容します。2025-11-25 仕様で CIMD が推奨方式として明記されました。

方式 仕組み メリット デメリット 向いているケース
Dynamic Client Registration(DCR / RFC 7591) クライアントが起動時に AS に登録 事前調整不要・任意のクライアント対応 DCR エンドポイントの abuse 対策が必要 OSS MCP / 個人開発・初期段階
Client ID Metadata Documents(CIMD) ⭐ 推奨 メタデータURLを公開して識別子に使う DCR の弱点(abuse)を回避・client_id がそのまま検証可能なURL クライアント側もメタデータURL公開が必要 中規模以上・新規実装
Static(事前登録) クライアントを AS に手動登録 最も予測可能・監査しやすい 配布のたびに事前登録が必要 エンタープライズ・パートナー連携

💡 ChatGPT が要求するクライアント識別:ChatGPT は 2025年11月以降、Developer Mode・Apps SDK 双方で OAuth Client ID(事前登録済み client_id または CIMD)を要求するようになりました(OpenAI Apps SDK AuthenticationOpenAI Developer Community: OAuth Client ID is no longer optional)。OpenAI 自身も CIMD を推奨しており、AS が対応しているなら CIMD が第一選択肢。対応していなければ Static(事前登録)を選びます。

🆕 CIMD の最小例(クライアント側)

// https://my-mcp-client.example.com/.well-known/oauth-client-metadata
{
  "client_id": "https://my-mcp-client.example.com/.well-known/oauth-client-metadata",
  "redirect_uris": ["https://my-mcp-client.example.com/oauth/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "mcp:tools mcp:resources"
}

サーバー(RS)側は、client_id がメタデータURLそのものなので、そのURLにアクセスして検証可能な点が CIMD の強みです(DCR で登録された不透明な client_id と違って、来歴を追跡できる)。

サーバー(AS / RS)側の CIMD 検証コード

import { lookup } from "node:dns/promises";

// metadata の最低限のスキーマ(型は zod 等で堅くすると尚良し)
type CimdMetadata = {
  client_id: string;
  redirect_uris: string[];
  token_endpoint_auth_method?: string;
  grant_types?: string[];
  response_types?: string[];
  scope?: string;
};

const CIMD_CACHE = new Map<string, { metadata: CimdMetadata; expiresAt: number }>();
const CIMD_CACHE_TTL_MS = 5 * 60 * 1000;  // 5分(長すぎるとリボーケ伝搬遅延)

// IPv4 / IPv6 の private / loopback / link-local をブロック
function isPrivateAddress(addr: string): boolean {
  if (/^(10\.|127\.|169\.254\.|0\.|255\.255\.255\.255)/.test(addr)) return true;
  if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
  if (/^192\.168\./.test(addr)) return true;
  // IPv6: loopback / unspecified / unique-local / link-local / mapped-IPv4 private
  if (/^(::1|::|fc[0-9a-f]{2}:|fd[0-9a-f]{2}:|fe80:)/i.test(addr)) return true;
  if (/^::ffff:(10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/i.test(addr)) return true;
  return false;
}

async function isHostnamePrivate(hostname: string): Promise<boolean> {
  // 文字列リテラル IP のショートサーキット
  if (isPrivateAddress(hostname)) return true;
  // DNS 解決して全アドレスを確認(DNS Rebinding 経路も塞ぐ)
  try {
    const addrs = await lookup(hostname, { all: true });
    return addrs.some((a) => isPrivateAddress(a.address));
  } catch {
    return true;  // 解決失敗は禁止扱い(fail-closed)
  }
}

async function validateCimdClientId(clientId: string): Promise<CimdMetadata> {
  // 1. client_id は HTTPS URL 必須(http / javascript:/ data: は拒否)
  let url: URL;
  try { url = new URL(clientId); } catch { throw new Error("invalid client_id URL"); }
  if (url.protocol !== "https:") throw new Error("client_id must be HTTPS");

  // 2. SSRF ガード:private / loopback / link-local IP を拒否
  if (await isHostnamePrivate(url.hostname)) {
    throw new Error("client_id host resolves to private/loopback IP");
  }

  // 3. キャッシュ HIT
  const cached = CIMD_CACHE.get(clientId);
  if (cached && cached.expiresAt > Date.now()) return cached.metadata;

  // 4. fetch(タイムアウト・サイズ上限を必ず設定)
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 5000);
  const res = await fetch(clientId, {
    headers: { Accept: "application/json" },
    signal: ac.signal,
    redirect: "error",  // ★ リダイレクトを禁止(SSRF 経路を塞ぐ)
  }).finally(() => clearTimeout(timer));

  if (!res.ok) throw new Error(`CIMD fetch failed: ${res.status}`);
  const text = await res.text();
  if (text.length > 16_384) throw new Error("CIMD metadata too large");

  const metadata = JSON.parse(text) as CimdMetadata;

  // 5. client_id 自己一致(メタデータ自身が URL と一致すると主張するか)
  if (metadata.client_id !== clientId) throw new Error("client_id mismatch in metadata");

  // 6. キャッシュ
  CIMD_CACHE.set(clientId, { metadata, expiresAt: Date.now() + CIMD_CACHE_TTL_MS });
  return metadata;
}

💡 Workers 環境ではさらに簡単:Cloudflare Workers の fetch は外向きのみ・private IP には到達しない設計なので、上記 isHostnamePrivate チェックは Node 自前デプロイ専用です。Workers なら redirect 禁止 + タイムアウト + サイズ上限の3点で十分です。

⚠️ CIMD は SSRF の温床client_id を URL として fetch する設計上、攻撃者が http://169.254.169.254/...(クラウドメタデータ)や内部 IP を渡してくる可能性があります。HTTPS 必須・リダイレクト禁止・タイムアウト・サイズ上限・private IP 範囲のブロックリストを必ず組み合わせてください(Workers では fetch が外向きのみ・private IP に届かないので軽減されますが、Node 自前デプロイなら明示的に塞ぐ必要があります)。

2-3: Protected Resource Metadata(.well-known/oauth-protected-resource

MCPサーバー(RS)が「自分はどの AS でトークン発行を受け付けるか」を公開するエンドポイントです。2025-11-25 仕様で RFC 9728 に正式整合しました。

app.get("/.well-known/oauth-protected-resource", (req, res) => {
  res.json({
    resource: "https://mcp.example.com",
    authorization_servers: [
      "https://auth.example.com",
      // 複数 AS をサポートする場合は並べる
    ],
    // ⚠️ scopes_supported は MCP 標準スコープではない。サーバー任意設計。
    //   下記は例示にすぎず、命名・粒度は各サーバーが決める。
    scopes_supported: ["mcp:tools", "mcp:resources", "mcp:prompts"],
    bearer_methods_supported: ["header"],
    resource_documentation: "https://mcp.example.com/docs",
  });
});

⚠️ resource フィールドは aud 検証の期待値と一致必須resource: "https://mcp.example.com" は RFC 9728 が定める RS の識別子で、クライアントは RFC 8707 の resource パラメータにこの値を渡してトークンを取得します。サーバー側は受信トークンの aud クレームがこの値と完全一致するかを検証する(Part 2-4)。PRM の resource と AS が発行する token の aud と RS の検証期待値、この 3 つを同一にすることが Token Passthrough 防止の本質です。

未認証リクエストには 401 + WWW-Authenticate を返す(ここでは存在チェックのみ。トークン検証本体はPart 2-4requireBearer に統合)

import type { RequestHandler } from "express";

const requireBearerPresence: RequestHandler = (req, res, next) => {
  const auth = req.header("Authorization");
  if (!auth?.startsWith("Bearer ")) {
    res.setHeader(
      "WWW-Authenticate",
      `Bearer realm="mcp.example.com", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"`
    );
    res.status(401).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: "Authentication required" },
      id: null,
    });
    return;
  }
  next();
};

このミドルウェアは Part 1-1 / Part 1-3 と同じくルート単位で挿入します(app.all("/mcp", ...) で別ルート登録すると Part 1-1 のハンドラと二重登録になります)。Part 2-4 の requireBearer は本ミドルウェアを内包した完成形なので、本番では requireBearer 一本で十分です。

💡 WWW-Authenticate は optional(2025-11-25 以降):仕様で optional 化され、/.well-known/oauth-protected-resource への fallback が定義されました。実装としては両方提供しておくのが堅実です。

2-4: aud 検証 / Token Passthrough 防止の実コード

最重要セクションです。ここを実装しないと、OWASP MCP07(Insufficient Authentication & Authorization)と MCP02(Privilege Escalation via Scope Creep)が即座に発火します。

💡 TypeScript 利用時の Express 型拡張:以降のコードは req.authInfo を多用します。プロジェクト直下に下記を 1 ファイル置けば、全コードで型補完が効きます。

// types/express.d.ts
import "express";

declare module "express-serve-static-core" {
  interface Request {
    authInfo?: {
      userId: string;
      scopes: string[];
      token: string;
      claims?: Record<string, unknown>;
    };
  }
}

❌ 危険な実装(Token Passthrough)

// 受け取ったトークンを下流APIにそのまま転送(MCP 仕様で明示的に禁止)
app.all("/mcp", async (req, res) => {
  const userToken = req.header("Authorization")?.replace("Bearer ", "");
  // 検証なしで GitHub API を叩く
  await fetch("https://api.github.com/user", {
    headers: { Authorization: `Bearer ${userToken}` },
  });
  // ...
});

このパターンは「ユーザーが GitHub から取った OAuth トークンを、MCP 経由で別の API に流す」中継器を作ってしまいます。

✅ 正しい実装:aud 検証 → 自サーバー名義トークン取得

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const EXPECTED_AUDIENCE = "https://mcp.example.com";
const EXPECTED_ISSUER = "https://auth.example.com";

async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    audience: EXPECTED_AUDIENCE,    // ★ aud 検証(Token Passthrough 防止)
    issuer: EXPECTED_ISSUER,        // ★ iss 検証
    // exp / nbf / iat は jose が自動検証
  });

  // ★ sub の存在チェック(jose の JWTPayload では sub: string | undefined)
  if (!payload.sub) throw new Error("token missing sub claim");

  // ★ jti ブラックリスト参照(Part 2-6-2 の Revocation 経路を実効化)
  if (payload.jti && (await jtiStore.exists(payload.jti))) {
    throw new Error("token revoked");
  }

  // OAuth 標準では scope は文字列だが AS によっては配列で返るため両対応
  const rawScope = payload.scope;
  const scopes: string[] = Array.isArray(rawScope)
    ? rawScope.map(String)
    : String(rawScope ?? "").split(" ").filter(Boolean);

  return {
    userId: payload.sub,
    scopes,
    claims: payload as Record<string, unknown>,
  };
}

// ミドルウェア化して Part 1-1 の各ルートに挿入する形(Part 1-3 と同じ流儀)
const requireBearer: RequestHandler = async (req, res, next) => {
  const token = req.header("Authorization")?.replace("Bearer ", "");

  // 認証失敗は Part 2-3 のポリシー通り「401 + WWW-Authenticate + JSON-RPC error」で返す
  const reject = (error: string, description?: string) => {
    res.setHeader(
      "WWW-Authenticate",
      `Bearer realm="mcp.example.com", error="${error}"` +
        (description ? `, error_description="${description}"` : "") +
        `, resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"`,
    );
    res.status(401).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: error, data: { description } },
      id: req.body?.id ?? null,
    });
  };

  if (!token) return reject("invalid_request", "Bearer token required");

  try {
    const verified = await verifyToken(token);
    req.authInfo = { ...verified, token };
  } catch (err: any) {
    return reject("invalid_token", err?.message ?? "token verification failed");
  }

  next();
};

// 適用例(Part 1-3 の requireProtocolVersion と並べる)
app.post  ("/mcp", express.json(), requireBearer, requireProtocolVersion, handleMcpPost);
app.get   ("/mcp", express.json(), requireBearer, requireProtocolVersion, handleMcpGet);
app.delete("/mcp", express.json(), requireBearer, requireProtocolVersion, handleMcpDelete);

📌 SDK ハンドラへの authInfo 引き渡し:Express の req.authInfo は HTTP 層の便利値ですが、Server.setRequestHandler(...)extra.authInfo には自動連携しません。SDK のバージョンによって注入方法が異なります(下記参照)。

// ─────────────────────────────────────────────────────────────────────────
// 【パターン A】SDK が extra.authInfo への直接注入をサポートしている場合
//   handleRequest の第4引数で渡せる SDK バージョン(v1.x 一部)では下記が使える。
//   デプロイ前に使用バージョンの型定義・リリースノートで必ず確認すること。
// ─────────────────────────────────────────────────────────────────────────
// await transports[sessionId!].handleRequest(req, res, req.body, {
//   authInfo: req.authInfo,   // SDK が対応していれば extra.authInfo に伝搬
// });

// ─────────────────────────────────────────────────────────────────────────
// 【パターン B】SDK v1.29.x 推奨:sessionId キーの Map で authInfo を受け渡す
//   SDK が第4引数を受け付けない場合はこちらを使う。
// ─────────────────────────────────────────────────────────────────────────
// ファイルスコープで宣言
// ⚠️ セッション終了時(transport.onclose)に delete する責務がある。
//   忘れるとセッション数に比例してメモリリークが起きる。
//   → Part 1-1 の `transport.onclose` ハンドラ内で
//     `authContextMap.delete(transport.sessionId)` を追加すること。
const authContextMap = new Map<string, NonNullable<Request["authInfo"]>>();

async function handleMcpPost(req: Request, res: Response) {
  const sessionId = req.header("Mcp-Session-Id");
  // ... transport 取得 / 初期化(Part 1-1 と同じロジック)
  // ★ 新規セッション初期化時に onclose でクリーンアップを登録する:
  //   transport.onclose = () => {
  //     if (transport.sessionId) {
  //       delete transports[transport.sessionId];
  //       authContextMap.delete(transport.sessionId);  // ← 追加
  //     }
  //   };

  // authInfo を sessionId に紐付けて保存
  if (sessionId && req.authInfo) {
    authContextMap.set(sessionId, req.authInfo);
  }
  await transports[sessionId!].handleRequest(req, res, req.body);
}

// SDK ハンドラ内での参照(パターン B の場合)
// server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
//   const authInfo = authContextMap.get(extra.sessionId ?? "") ?? extra.authInfo;
//   const userId = authInfo?.userId;
//   // ...
// });

下流 API には MCPサーバー名義の別トークンを取得して呼ぶ

// トークン交換(RFC 8693)またはサービスアカウント
async function callGitHub(userId: string, endpoint: string) {
  const downstreamToken = await tokenStore.getDownstreamToken(userId, "github");
  return fetch(`https://api.github.com${endpoint}`, {
    headers: {
      Authorization: `Bearer ${downstreamToken}`,
      "User-Agent": "mcp.example.com/1.0",  // 監査証跡に残す
    },
  });
}

📌 aud 検証の3点確認

  1. トークンの aud クレームが自サーバーURL と一致するか
  2. トークンの iss クレームが想定 AS と一致するか
  3. JWKS で署名検証し、exp / nbf / iat を確認しているか

この3つを満たさないトークンは すべて 401 で拒否します。前編 4-4「Confused Deputy / Token Passthrough」で扱った概念の、具体的な実装版です。

2-5: Refresh Token Rotation・短命トークン

長寿命トークンは OWASP MCP01(Token Mismanagement)の最大の温床です。

📌 トークン寿命の目安

種別 推奨 TTL 失効方式
Access Token 15分〜1時間 自然失効
Refresh Token 30日(rotation 有効化) 使い捨て+ローテーション
Authorization Code 60秒以下 一度使ったら無効

Refresh Token Rotation の実装ポイント

// ⚠️ OAuth トークンエンドポイントは application/x-www-form-urlencoded(RFC 6749 §3.2)。
//   express.json() だけでは body が空になるため、専用 router に urlencoded を当てる。
const oauthRouter = express.Router();
oauthRouter.use(express.urlencoded({ extended: false }));

// サーバーが信頼する RS 識別子のホワイトリスト(任意 aud 取得を防ぐ)
const ALLOWED_RESOURCES = new Set([
  "https://mcp.example.com",
]);

oauthRouter.post("/token", async (req, res) => {
  const { grant_type, refresh_token, client_id, resource } = req.body;

  if (grant_type !== "refresh_token") {
    return res.status(400).json({ error: "unsupported_grant_type" });
  }

  // ★ resource ホワイトリスト検証(攻撃者が任意の aud を要求するのを防ぐ)
  if (!resource || !ALLOWED_RESOURCES.has(resource)) {
    return res.status(400).json({ error: "invalid_target" });
  }

  // 1. リフレッシュトークンを検証
  const stored = await tokenStore.findRefreshToken(refresh_token);
  if (!stored || stored.usedAt) {
    // ★ 再利用検出 → そのチェーン全体を失効(盗用対策)
    if (stored?.chainId) await tokenStore.revokeChain(stored.chainId);
    return res.status(400).json({ error: "invalid_grant" });
  }

  // 2. 古いトークンを使用済みマーク
  await tokenStore.markUsed(refresh_token);

  // 3. 新しいペアを発行(aud はホワイトリスト検証済みの resource)
  const newAccess = await issueAccessToken({ sub: stored.userId, aud: resource });
  const newRefresh = await issueRefreshToken({
    userId: stored.userId,
    chainId: stored.chainId,  // ★ 同じチェーンIDを維持
  });

  res.json({
    access_token: newAccess,
    refresh_token: newRefresh,
    token_type: "Bearer",
    expires_in: 3600,
  });
});

app.use("/oauth", oauthRouter);

💡 再利用検出(Refresh Token Reuse Detection):使用済みリフレッシュトークンが再度提示された場合、そのチェーン全体を失効させます。これは「攻撃者が盗んだ古いトークンを使った」サインなので、正規ユーザーのトークンも巻き込んで失効させ、再ログインを強制します。OAuth 2.1 ベストプラクティスの定石です。

2-6: Access Token と ID Token / userinfo の使い分け

MCPサーバーが必要な認証情報は (a) ユーザーが誰か(b) 何を許可されたか の 2 つです。OAuth 2.1 / OIDC ではそれぞれ別のトークン・エンドポイントに分かれます。

情報 取り方 用途
sub(ユーザー識別子) Access Token の sub クレーム per-user ストレージのキー・ログ主キー
scope(権限) Access Token の scope クレーム ツールの可視性・実行可否
name / email / picture OIDC userinfo エンドポイント or ID Token 監査ログ表示・通知メール送信

⚠️ 「Access Token にユーザー名を入れてもらう」のは AS 設計次第:AS がカスタムクレームに name / email を載せていれば 1 リクエストで済みますが、OIDC 標準では profile / email スコープを要求して userinfo を叩くのが正規ルートです。本記事のコードは sub のみで成立する設計にしていますが、表示名が必要なら:

// 認証時に userinfo を引いてキャッシュ(per-user TTL 1h など)
async function getUserProfile(userId: string, accessToken: string) {
  const cached = await prefsStore.get(`profile:${userId}`);
  if (cached) return cached;
  const res = await fetch("https://auth.example.com/userinfo", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  const profile = await res.json();
  await prefsStore.set(`profile:${userId}`, profile, { ttl: 3600 });
  return profile;
}

2-6-2: Token Revocation(RFC 7009)と Introspection(RFC 7662)

Refresh Token Rotation は「使い捨て」による失効ですが、ユーザー操作でのログアウト・盗用検知時の即時失効には別の経路が必要です。

Token Revocation(RFC 7009)の最小実装

oauthRouter.post("/revoke", async (req, res) => {
  const { token, token_type_hint } = req.body;
  if (!token) return res.status(400).json({ error: "invalid_request" });

  // hint があれば優先・無ければ両方試す
  const types = token_type_hint
    ? [token_type_hint]
    : ["refresh_token", "access_token"];
  for (const type of types) {
    if (type === "refresh_token") {
      const r = await tokenStore.findRefreshToken(token);
      if (r) await tokenStore.revokeChain(r.chainId);
    } else if (type === "access_token") {
      // JWT の場合は jti をブラックリストに追加
      // tryDecode は jose.decodeJwt をラップしたもの(失敗時に null を返すヘルパー):
      //   import { decodeJwt } from "jose";
      //   const tryDecode = (t: string) => { try { return decodeJwt(t); } catch { return null; } };
      const claims = tryDecode(token);
      if (claims?.jti) await tokenStore.blacklistJti(claims.jti, Number(claims.exp ?? 0));
    }
  }
  // RFC 7009 §2.2:成否にかかわらず 200 を返す(情報漏洩防止)
  return res.status(200).end();
});

📌 JWT の即時失効には jti ブラックリスト必須:JWT は仕様上、署名鍵ローテーション以外で失効させる手段がありません。jti クレームを必ず付与し、exp までブラックリストに残す**短命キャッシュ(Redis / KV)**で対応します。長寿命の Access Token は使わない方針なら、ブラックリスト不要なケースもあります(リスク許容度次第)。

Token Introspection(RFC 7662):opaque token を使う場合

JWT 以外のフォーマット(ランダム文字列を AS の DB で照合)を使う AS の場合、RS は AS の /introspect を叩いてトークンの妥当性を問い合わせる形になります。

async function introspectToken(token: string) {
  const res = await fetch("https://auth.example.com/oauth/introspect", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${Buffer.from(`${RS_ID}:${RS_SECRET}`).toString("base64")}`,
    },
    body: new URLSearchParams({ token, token_type_hint: "access_token" }),
  });
  const data = await res.json();
  if (!data.active) throw new Error("inactive token");
  // ★ aud / iss / scope 検証は JWT と同じ
  if (data.aud !== EXPECTED_AUDIENCE) throw new Error("aud mismatch");
  return data;
}

⚠️ introspect は AS への往復が発生する :1リクエストあたり1往復が追加コストです。短命キャッシュ(5〜60秒) を挟むのが現実解ですが、キャッシュ TTL は「失効伝搬の最大遅延」と等価なので、セキュリティ要件と相談して決めます。JWT が選べるなら JWT が高速で、introspect は「AS 側が opaque token を強制するときの fallback」です。

2-6-3: Confidential vs Public Client / DPoP(Sender-Constrained Token)

MCPサーバーのクライアントは大きく2種類に分かれます。

種別 認証手段 想定
Public Client PKCE のみ(client_secret なし) Claude Desktop / Cursor 等のデスクトップアプリ
Confidential Client client_secret / client_secret_jwt / private_key_jwt サーバー間(M2M)連携・社内 SaaS

本記事のここまでの実装は Public Client + PKCE を前提にしています。Confidential Client を受け入れる場合は、token_endpoint_auth_method を AS metadata で複数公開し、/token エンドポイントで client 認証を分岐します。

💡 private_key_jwt(RFC 7523):client_secret を漏らさない・ローテーション容易・監査しやすい。エンタープライズ顧客から要求されたら採用を検討します。

DPoP(RFC 9449)= Sender-Constrained Token

Bearer Token は「持っている人が誰でも使える」性質があり、漏洩時の被害が大きい。DPoP は HTTP リクエストごとに 鍵ペアによる署名 JWT(DPoP proof) を付けることで、トークン単体を盗まれても署名鍵がなければ使えない仕組みを提供します。

POST /mcp HTTP/1.1
Authorization: DPoP eyJ...   // Bearer ではなく DPoP スキーム
DPoP: eyJ...                  // リクエストごとに署名された proof JWT

⚠️ 本記事の他コードは Bearer 前提:DPoP を採用する場合は、検証ミドルウェアで htm(HTTP method)htu(URI)iat(5分以内)の検証を追加し、proof の jkt(key thumbprint)が Access Token の cnf.jkt と一致するかも検証します。エンタープライズ・規制業種で Bearer の bearer-性が許容されないときの選択肢として記憶しておけば十分です。

2-6-4: トークンのクエリ文字列禁止と JTI Replay 対策

📌 Bearer/DPoP トークンを URL クエリ文字列に載せてはいけない(RFC 6750 §2.3 / OWASP)。理由は:

  • アクセスログ / Web サーバーログにそのまま記録される(CloudFront / ALB / nginx access.log)
  • Referer ヘッダで第三者サイトに漏洩
  • ブラウザ履歴に残る

MCPサーバーは Authorization ヘッダ以外でのトークン受信を明示的に拒否します。

app.use((req, res, next) => {
  if (req.query.access_token || req.query.token) {
    return res.status(400).json({
      jsonrpc: "2.0",
      error: { code: -32600, message: "Bearer in URL is forbidden" },
      id: null,
    });
  }
  next();
});

Replay 対策(JTI ブラックリスト + 短命)

async function verifyAndConsumeJti(claims: { jti?: string; exp?: number }) {
  if (!claims.jti) throw new Error("jti required");
  const seen = await jtiStore.exists(claims.jti);
  if (seen) throw new Error("replay detected");
  await jtiStore.set(claims.jti, true, { ttl: (claims.exp ?? 0) - Math.floor(Date.now() / 1000) });
}

💡 JTI が必須なのは「一度きり消費」のセマンティクスを持つトークン:通常の Access Token は短命なので JTI 必須ではないですが、Authorization Code・Refresh Token・DPoP proof は必ず JTI で replay 防止します。

2-6-5: OIDC 認証強度パラメータ(エンタープライズ向け)

エンタープライズ顧客から「機微操作の前には MFA を再要求してほしい」「同意画面を毎回出してほしい」と要求されることがあります。OIDC のオプションパラメータで実装できます。

パラメータ 意味 用途
prompt=consent 同意画面を強制的に再表示 プラン変更・スコープ拡張時
prompt=login ログインを強制的に再要求 機微操作(決済・退会)の直前
max_age=300 「5分以内に認証されたセッション」を要求 step-up 認証
acr_values=urn:mace:incommon:iap:silver 認証クラスの最低要件(例:MFA 済み) 高セキュリティ操作
// クライアント側:機微操作前に re-authentication を要求
const authUrl = new URL("https://auth.example.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("resource", "https://mcp.example.com");  // RFC 8707
authUrl.searchParams.set("prompt", "login");                       // ★ 再ログイン強制
authUrl.searchParams.set("max_age", "300");                        // ★ 5分以内の認証

サーバー側は受信トークンの auth_time クレームを見て、要求レベルを満たさなければ 403 + WWW-Authenticate: ... error="insufficient_user_authentication" を返します。

2-7: DCR の abuse 対策

Part 2-2 で CIMD を推奨しましたが、DCR を残す場合の abuse 対策は以下を組み合わせます。

  1. /oauth/register への IP/Origin 単位レート制限(10 req/min など厳しめ)
  2. Initial Access Token(RFC 7591 §3):DCR を呼ぶ前に AS が発行する短命トークンを要求
  3. software_statement(RFC 7591 §2.3):信頼チェーン(例:IdP が JWT 形式でクライアント素性を署名)を要求
  4. CAPTCHA / Cloudflare Turnstile:ブラウザ経由の登録のみに限定する場合
  5. DCR で発行する client_secret を短命化:登録後 24h で失効、長期利用は信頼チェーン提示を要求

Part 3: リモートMCP固有のセキュリティ

3-1: Confused Deputy(概念と実装)

前編 4-4 で概念を扱いましたが、ここではなぜリモートMCP固有なのかを再整理してから、対策の実コードを示します。

Confused Deputy(混乱した代理)とは

MCPサーバーが下流のサードパーティ API(GitHub / Slack / Notion 等)へのプロキシとして動作する構成で、攻撃者が MCPサーバーの信頼を利用して、本来できないアクションを実行させる攻撃です。

なぜリモート固有か

  • ローカル(stdio):ユーザーPC内で1プロセス1ユーザー。下流APIへのアクセス資格はユーザーPCに直接置かれるため、代理(deputy)が存在しない
  • リモート(HTTP):MCPサーバーが全ユーザーの代理として下流APIを叩くため、「誰のために何を呼ぶか」が混乱しうる

具体的には、MCPサーバーが静的な OAuth Client ID で下流プロバイダ(例:GitHub)に登録されていると、攻撃者が DCR + 既存の consent cookie を組み合わせて、ユーザーの同意ステップをすり抜け、攻撃者のリダイレクトURLにトークンを誘導する経路が成立し得ます。

対策の実装

// 1. 下流プロバイダのリダイレクトURLをホワイトリスト化
const ALLOWED_REDIRECT_URIS = new Set<string>([
  "https://mcp.example.com/oauth/github/callback",
]);
const DEFAULT_REDIRECT_URI = "https://mcp.example.com/oauth/github/callback";

app.get("/oauth/github/callback", async (req, res) => {
  const { code, state } = req.query;

  // 2. state を必ず検証(CSRF + Confused Deputy 対策)
  // 📌 本記事の時刻単位ポリシー:
  //   - JS / 内部ストアのフィールド(expiresAt 等)は **ミリ秒(Date.now() 互換)**
  //   - JWT クレーム(exp / iat / nbf / plan_expires_at)は RFC 7519 に従い **秒**
  //   - 比較時は必ず `claims.exp * 1000 < Date.now()` のように換算する
  const stateData = await stateStore.consume(state as string);
  if (!stateData || stateData.expiresAt < Date.now()) {
    return res.status(400).send("Invalid state");
  }

  // state.redirect_uri がある場合はホワイトリスト検証して採用、無ければ default
  const redirectUri = stateData.redirectUri ?? DEFAULT_REDIRECT_URI;
  if (!ALLOWED_REDIRECT_URIS.has(redirectUri)) {
    return res.status(400).send("redirect_uri not allowed");
  }

  // 3. ユーザーごとの consent を強制(cookie ベースの自動同意を許さない)
  // `userExplicitlyConsented` は /consent の POST ハンドラで true に更新する:
  //   await stateStore.put(state, { ...stateData, userExplicitlyConsented: true });
  //   その後 /oauth/github/callback へ再リダイレクトして、本フローを再実行する。
  if (!stateData.userExplicitlyConsented) {
    return res.redirect(`/consent?provider=github&state=${state}`);
  }

  // 4. 下流トークン取得(GitHub は form-urlencoded が標準・Accept で JSON 応答を要求)
  const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
    body: new URLSearchParams({
      client_id: process.env.GITHUB_CLIENT_ID!,
      client_secret: process.env.GITHUB_CLIENT_SECRET!,
      code: String(code),
      redirect_uri: redirectUri,
    }),
  });

  const tokens = await tokenRes.json();

  // 5. ユーザーIDに紐付けて暗号化保存
  await tokenStore.saveDownstream(stateData.userId, "github", tokens, {
    encryption: "aes-256-gcm",
  });

  res.redirect("/connected");
});

📌 Confused Deputy 防止の4点セット

  1. state パラメータの厳格検証(一度使ったら破棄、有効期限付き)
  2. redirect_uri のホワイトリスト
  3. per-user consent の強制(cookie 経由の自動同意を禁止)
  4. 下流トークンはユーザーIDに紐付けて暗号化保存

3-2: Rug Pull の脅威モデルがLocal/Remoteで違う理由

Rug Pull Attack は「一度信頼されたMCPサーバーを後から書き換えて悪意ある動作を追加する」攻撃です。ローカルとリモートで影響範囲が根本的に違います

観点 ローカル(stdio) リモート(HTTP)
発火条件 ユーザーが @latest を使い、新バージョンを取得した瞬間 作者がサーバー側コードを書き換えた瞬間
影響範囲 更新したユーザーのみ・順次 全ユーザー即時
検知可能性 バージョン番号で察知可能 サーバー側挙動の変化なので察知困難
対策 バージョン固定(@0.1.38 等) 監査ログ / バージョン署名 / ユーザー通知

リモートMCP作者として実装すべき3点

  1. 不変なバージョン文字列を tools/list のメタデータに含める:クライアント側で「前回と違う」を検知可能に
  2. ツール定義の変更を監査ログに残す:誰がいつ何を変えたかを別ストアに記録
  3. 破壊的変更前にユーザー通知:メール・ダッシュボード・tools.listChanged 通知でアナウンス
// tools/list レスポンスにビルドメタを含める
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: TOOLS.map((t) => ({
    ...t,
    _meta: {
      "example.com/build_id": process.env.BUILD_ID,         // Git SHA など
      "example.com/build_signed_at": process.env.BUILD_AT,
      "example.com/signature": process.env.BUILD_SIGNATURE, // 後述の通り「別系統で生成」が必須
    },
  })),
}));

⚠️ 署名は「サーバー自身が埋めるだけ」では Rug Pull 抑止にならない:攻撃者がサーバーへの書き込み権限を奪った場合、BUILD_SIGNATURE 環境変数も書き換えられます。意味のある署名にするには、CI/CD パイプライン上で、デプロイ権限を持たない別チームの鍵でツール定義 JSON のハッシュに署名し、その公開鍵を https://example.com/.well-known/mcp-signing-keys.json 等で別ドメイン配信します。クライアント側は受信した signature を独立配信の公開鍵で検証することで、初めて「サーバー単独では偽造不可能な証跡」になります。

📌 Rug Pull 防止の信頼チェーン

  1. CI/CD パイプライン(コミット→デプロイ)で、ツール定義のハッシュに 別チーム管理の鍵で署名
  2. 署名と公開鍵は MCPサーバーとは別の配信経路(独立ドメイン / Sigstore / 公証ログ)
  3. クライアント/レジストリは公開鍵を pin して、tools/list 受信時に署名検証
  4. ハッシュ変化をユーザー通知のトリガーにする(メール・ダッシュボード)

📌 監査ログの推奨配信先(tamper-proof)

配信先 特性 用途
Sigstore Rekor 透明性ログ・改ざん検知可能・公開 OSS MCP のツール定義ハッシュ追記
AWS S3 Object Lock(WORM) Retention 期間内は削除・上書き不可 エンタープライズの監査要件
Cloudflare R2 + Object Versioning バージョン管理・削除に追跡可能 Workers 環境との親和性
append-only ログ(loki / OpenSearch) 追記のみで運用、削除権を分離 SIEM 連携前提

⚠️ 「MCPサーバー自身が自分の監査ログを書ける」設計は無意味:書き込み権限を奪われた攻撃者はログも改ざんできます。必ず 書き込み専用 IAM ロールを使い、読み取り・削除権限はサーバープロセスに与えないでください。

Sigstore Rekor で tools/list のハッシュを公証する最小例

# 1. ツール定義 JSON を生成(CI/CD で)
node ./scripts/dump-tools-manifest.js > tools-manifest.json

# 2. cosign で署名 + Rekor 透明性ログに記録(keyless モードで OIDC 認証)
cosign attest --yes \
  --predicate tools-manifest.json \
  --type "https://mcp.example.com/predicate/tools-manifest/v1" \
  --rekor-url https://rekor.sigstore.dev \
  ghcr.io/example/mcp-server:${GIT_SHA}

# 3. デプロイ時に署名を環境変数または別ドメインに配信
cosign verify-attestation --type "https://mcp.example.com/predicate/tools-manifest/v1" \
  ghcr.io/example/mcp-server:${GIT_SHA}

クライアント側は Rekor のエントリ ID を tools/list_meta で受信し、独立配信されている公開鍵で署名検証します。Rekor の透明性ログは追記専用なので、攻撃者がサーバーに侵入してもログ自体は書き換えられません。

⚠️ 静的解析ツール(mcp-scan / snyk-agent-scan)の限界:これらは初回接続時の tools/list を基準に判定するため、サーバー側 Rug Pull には弱いことが前編 4-6で説明されています。リモートMCPの利用者側はランタイム監視モードとの併用が必須です。

3-3: CORS / Origin / DNS Rebinding

ブラウザクライアント(Claude.ai Web など)からのアクセスを受ける場合、CORS と Origin 検証は避けて通れません。

❌ 危険な設定

// import 省略
app.use(cors({ origin: "*", credentials: true }));  // ワイルドカード許可は禁止

✅ 正しい設定(cors パッケージで関数指定)

import cors from "cors";

const ALLOWED_ORIGINS = new Set([
  "https://claude.ai",
  "https://chatgpt.com",
  // 必要なドメインのみ
]);

app.use(cors({
  origin: (origin, cb) => {
    // origin === undefined は同一オリジン / 直アクセス。許可する場合は注意。
    if (!origin || ALLOWED_ORIGINS.has(origin)) return cb(null, true);
    return cb(new Error("Not allowed by CORS"));
  },
  // ⚠️ credentials: true は「Cookie で /authorize の同意セッションを維持する」用途。
  //   MCP 本体(POST /mcp)は Authorization ヘッダで Bearer 認証するため、
  //   /authorize と /consent ルートのみ Cookie を必要とする。Bearer 専用で
  //   Cookie を一切使わない設計なら credentials: false にできる。
  credentials: true,
  allowedHeaders: [
    "Authorization", "Content-Type",
    "MCP-Protocol-Version", "Mcp-Session-Id",
  ],
  exposedHeaders: ["Mcp-Session-Id"],  // クライアント JS から読む必要があるのは Mcp-Session-Id のみ
  maxAge: 86400,
}));

DNS Rebinding 対策(特にローカルで HTTP を立てる場合)

// ⚠️ localhost:3000 はローカル開発専用。本番ビルドでは絶対に含めない。
//   NODE_ENV / ENVIRONMENT 等で環境分岐すること。
const ALLOWED_HOSTS = new Set(
  process.env.NODE_ENV === "production"
    ? ["mcp.example.com"]
    : ["mcp.example.com", "localhost:3000"]
);

app.use((req, res, next) => {
  const host = req.header("Host");
  if (!host || !ALLOWED_HOSTS.has(host)) {
    return res.status(403).send("Forbidden host");
  }
  next();
});

💡 MCP Inspector の CVE-2025-49596 は DNS Rebinding が起点でした(CVSS 9.4 Critical、2025-06-13 公開、Oligo Security 報告)。Origin / Host 検証は「ローカルだから安全」が成立しないことを実証した実例です。

3-3-2: TLS 要件と HTTP セキュリティヘッダ

「TLS 必須」の1行ではなく、具体的な要件を明示します。

📌 TLS 設定の最低要件(2026年5月時点)

項目 要件 根拠
プロトコル最低 TLS 1.2 以上(TLS 1.3 強く推奨) TLS 1.0/1.1 は IETF deprecate
cipher suite AEAD のみ(GCM / ChaCha20-Poly1305) RC4 / 3DES / CBC は禁止
証明書 RSA 2048 / ECDSA P-256 以上 1024bit RSA は禁止
HSTS max-age=31536000; includeSubDomains; preload Strict-Transport-Security 必須
OCSP Stapling 有効化(CDN / リバースプロキシで対応) プライバシー・性能
// 全ルート共通の最低限ヘッダ(HSTS / nosniff / Referrer-Policy)
app.use((req, res, next) => {
  res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("Referrer-Policy", "no-referrer");
  res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
  next();
});

// ⚠️ CSP は「ルート種別ごと」に分けて適用する。
//   `default-src 'none'` は API ルートでは安全だが、`/authorize` のような
//   HTML ログイン UI を返すルートに適用するとフォーム・CSS・JS が全部ブロックされる。

// 1. API ルート(JSON のみ・HTML 不返却)には最厳 CSP
app.use(["/mcp", "/oauth", "/.well-known", "/webhooks"], (req, res, next) => {
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
  next();
});

// 2. HTML を返すルート(同意画面・エラーページ)は同一オリジン許可
app.use(["/authorize", "/consent", "/error"], (req, res, next) => {
  res.setHeader(
    "Content-Security-Policy",
    "default-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
  );
  next();
});

⚠️ Cloudflare Workers の場合の特例:HSTS / OCSP Stapling は Cloudflare 側で自動付与されます(SSL/TLS タブで設定)。Workers コード内で再設定する必要はありません。ただし X-Content-Type-Options 等は Workers レイヤで付与します。

💡 CORS とセキュリティヘッダの順序:CORS ミドルウェアよりにセキュリティヘッダを設定すると、preflight (OPTIONS) のレスポンスにもヘッダが乗ります。順序を逆にすると preflight でヘッダが落ちることがあります。

3-4: レート制限・abuse 対策

リモートMCPは全世界のクライアントから叩かれる前提で組みます。

📌 多軸レート制限

想定する濫用パターン 推奨制限
ユーザー単位sub クレーム) 正規ユーザーの暴走 100 req/min
OAuth Client ID 単位 悪意あるクライアント実装 1000 req/min
IP 単位 認証前フェーズの abuse 10 req/min(未認証)/ 1000 req/min(認証後)
ツール単位 高コストツールの濫用 ツール定義に _meta.rate_limit_per_min
テナント単位(プラン別) プラン契約超過 プラン定義に応じて

多軸を rate-limiter-flexible で実装する例(Node ランタイム)

import { RateLimiterMemory } from "rate-limiter-flexible";

// 軸ごとに限界値を変えたリミッターを並列に持つ
const limiters = {
  user:   new RateLimiterMemory({ points: 100,  duration: 60 }),
  client: new RateLimiterMemory({ points: 1000, duration: 60 }),
  ip:     new RateLimiterMemory({ points: 1000, duration: 60 }),
  ipAnon: new RateLimiterMemory({ points: 10,   duration: 60 }),  // 未認証
};

app.use(async (req, res, next) => {
  const ip = req.ip ?? "unknown";
  const userId = req.authInfo?.userId;
  const clientId = req.authInfo?.claims?.client_id as string | undefined;

  try {
    // 認証前は IP 単位のみ(厳しめ)
    if (!userId) {
      await limiters.ipAnon.consume(ip);
    } else {
      // 認証後は user + client + ip の3軸を AND で消費(最も厳しいものが効く)
      // ⚠️ Promise.all で並列消費すると、1つが reject した時点で他はすでに
      //   カウンタを加算済みでも reward されない。短時間に偏ったリクエストが
      //   集中する場面では「正規ユーザーが二重に消費される」副作用があるため、
      //   下記の順次 consume パターンを使う。
      // 【参考:並列版(副作用あり)】
      //   await Promise.all([
      //     limiters.user.consume(userId),
      //     clientId ? limiters.client.consume(clientId) : Promise.resolve(),
      //     limiters.ip.consume(ip),
      //   ]);
      const targets: Array<[{ consume(key: string): Promise<unknown> }, string]> = [
        [limiters.user, userId],
        ...(clientId ? [[limiters.client, clientId] as const] : []),
        [limiters.ip, ip],
      ];
      for (const [limiter, key] of targets) {
        await limiter.consume(key);  // 失敗時は catch がキャッチして 429 を返す
      }
    }
    next();
  } catch (rejRes: any) {
    const retryAfter = Math.ceil((rejRes.msBeforeNext ?? 60000) / 1000);
    res.setHeader("Retry-After", String(retryAfter));
    res.status(429).json({
      jsonrpc: "2.0",
      error: {
        code: -32000,
        message: "Rate limit exceeded",
        data: {
          retry_after_seconds: retryAfter,
          retry_allowed: true,        // AI が自己修正してリトライ可能
        },
      },
      id: req.body?.id ?? null,
    });
  }
});

ツール単位・テナント単位の制限は tools/call ハンドラ内で limiters.tool / limiters.tenant を消費する形で重ねます(実装は省略)。

⚠️ Cloudflare Workers では RateLimiterMemory は使えない:Workers の isolate は短命・per-edge で、プロセスメモリが永続しません。上記コードをそのまま Workers にコピペすると 「ヒットしないリミッター」になる(毎回新しい isolate でカウンタがゼロから)ので注意。Workers では次の3択になります。

選択肢 特徴 向いているケース
Cloudflare 公式 Rate Limiting バインディング wrangler.toml に宣言、env.MY_LIMITER.limit({ key }) で1行 グローバル整合性が不要・固定窓で良い
Durable Objects ベース自前実装 識別子ごとに同一 DO へルーティング、storage.transaction() で原子更新 グローバル一貫・任意アルゴリズム(token bucket 等)
外部 Redis(Upstash Edge) RateLimiterRedisrate-limiter-flexible から呼ぶ Node 資産を流用したい・複数ランタイム横断
// Cloudflare 公式 Rate Limiting バインディング(最小例)
// wrangler.toml:
//   [[unsafe.bindings]]
//   name = "USER_LIMITER"
//   type = "ratelimit"
//   namespace_id = "1001"
//   simple = { limit = 100, period = 60 }
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const userId = await getUserIdFromBearer(req, env);
    const { success } = await env.USER_LIMITER.limit({ key: userId });
    if (!success) {
      return new Response(JSON.stringify({
        jsonrpc: "2.0",
        error: { code: -32000, message: "Rate limit exceeded" },
        id: null,
      }), { status: 429, headers: { "Retry-After": "60" } });
    }
    // ...
  },
};

💡 retry_allowed: true + Retry-After の組み合わせは、後編 Part 5 の構造化エラーパターンを HTTP に移植したものです。AI クライアントが exponential backoff でリトライできます。


Part 4: マネタイズの「フックポイント設計」

💰 本Part全体がマネタイズ章です。アイコンは省略します。

リモートMCPは「AIプラグインの販売プラットフォーム」になりつつあります。本記事ではStripe / Paddle の実装詳細には立ち入らず、課金エンジンに何を渡すか・どこにフックを置くかという設計だけを扱います。実装は読者の選んだ決済プロバイダのドキュメントを参照してください。

4-1: 課金モデル別の OAuth スコープ設計

スコープは「ユーザーがこのアプリに何を許可したか」を表す OAuth の標準機能ですが、マネタイズでは「プラン契約に応じて何のツールを露出するか」のフックとしても使えます。

スコープ設計の3パターン

課金モデル スコープ設計
サブスク(プラン別) プラン名をスコープに plan:free, plan:pro, plan:team, plan:enterprise
機能別アンロック 機能粒度のスコープ tool:analyze, tool:export, tool:bulk
エンタープライズ(テナント別) テナントIDをスコープ or claim に tenant:acme-corpplan:enterprise と併用)

💡 プランとテナントは直交するplan:* は「機能の解放度合」、tenant:* は「データの所属組織」。エンタープライズ顧客は plan:enterprise + tenant:acme-corp の両方を持ち、両者で AND を取って認可判定します。

OAuth トークンの carriers

// access_token の中身(JWT デコード後)
{
  "sub": "user_abc123",
  "aud": "https://mcp.example.com",
  "iss": "https://auth.example.com",
  "scope": "mcp:tools plan:pro tenant:acme-corp",
  "plan": "pro",                       // ★ カスタムクレーム
  "plan_expires_at": 1798761600,       // ★ プラン期限(2027-01-01 UTC)
  "tenant_id": "acme-corp",            // ★ マルチテナント識別
  "exp": 1779235200                    // 2026-05-18 から約8か月後
}

💡 スコープとカスタムクレームの使い分け:スコープは「ユーザーが同意した範囲」、カスタムクレームは「サーバーが信頼できる事実」。プラン情報はカスタムクレームに入れ、aud 検証時に同じ JWKS で署名検証されることで改ざんを防ぎます。

⚠️ Access Token にカスタムクレームを乗せられるかは AS 実装次第:OIDC 規約上、profile / email / name / locale などのユーザー属性は本来 ID Token に乗るクレームです。Auth0 / Cognito / Keycloak はカスタム Action / Hook で Access Token にもクレームを追加できる機能を提供しますが、素の OAuth 2.1 AS では Access Token は最小化が原則で、追加属性は userinfo を引く必要があります(Part 2-6)。本記事のコードで claims.plan / claims.locale を Access Token から読んでいる箇所は、AS 側で 以下のいずれかの設定が必要です:

AS 設定方法
Auth0 Actions → Login flow に api.accessToken.setCustomClaim("https://mcp.example.com/plan", user.app_metadata.plan)
Cognito Lambda Trigger(Pre Token Generation v2.0)で claimsToAddOrOverride に追加
Keycloak Client Scope → Mappers → User Attribute / Hardcoded Claim
自前 AS トークン発行時に DB から user.plan を読んで JWT クレームに含める

OIDC 標準クレームと衝突を避けるため、カスタムクレームは URL 形式(https://mcp.example.com/plan)にするのが Auth0 推奨パターンです。本記事の plan / tenant_id も本番ではこの命名規約に揃えるのが安全です。

4-2: Dynamic Toolset によるプラン別ツール出し分け

tools/list のレスポンスを認証済みユーザーのプランに応じて変えるのがリモートMCPならではの強みです。stdio では実現困難でした(プロセスがユーザー識別を持たないため)。

const ALL_TOOLS = [
  { name: "search",      requiredPlan: "free" },
  { name: "analyze",     requiredPlan: "pro" },
  { name: "bulk_export", requiredPlan: "team" },
  { name: "ai_compose",  requiredPlan: "pro" },
];

const PLAN_HIERARCHY = ["free", "pro", "team", "enterprise"];

function isPlanSufficient(userPlan: string, required: string) {
  return PLAN_HIERARCHY.indexOf(userPlan) >= PLAN_HIERARCHY.indexOf(required);
}

server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
  const userPlan = extra.authInfo?.claims?.plan ?? "free";
  const tools = ALL_TOOLS
    .filter((t) => isPlanSufficient(userPlan, t.requiredPlan))
    .map((t) => ({
      ...t,
      _meta: {
        "example.com/required_plan": t.requiredPlan,
        "example.com/current_plan": userPlan,
      },
    }));

  return { tools };
});

📌 プラン変更時の tools/list_changed 通知

// プランアップグレード後に呼ぶ
async function notifyPlanChanged(userId: string) {
  const sessions = await sessionStore.findByUserId(userId);
  for (const session of sessions) {
    const transport = transports[session.id];
    if (!transport) continue;
    // 特定ユーザーセッションへの通知は transport.send() で直接送る。
    // server.notification() は「現在 SDK が保持している transport 1つ」への送信であり、
    // マルチテナント環境では意図しないセッションに届く可能性がある。
    // ユーザー単位で絞る場合は必ず transport レイヤを経由する。
    await transport.send({
      jsonrpc: "2.0",
      method: "notifications/tools/list_changed",
      params: {},
    });
  }
}

⚠️ tools/list_changed は「再フェッチ要求」であって新しいツール一覧そのものではない:MCP 仕様では、クライアントはこの通知を受け取ったら 改めて tools/list を発行する義務があります。サーバー側は通知だけ送って終わり、新しい一覧は次の tools/list リクエスト時に作って返します。プラン変更が UI に反映されない事故の典型原因は、(a) クライアント側で tools/list_changed を購読していない、(b) サーバー側でユーザー認証済みトークンに紐づく Access Token のプランクレームが更新されていない(古い JWT が refresh されるまで効かない)、の 2 つです。プランアップグレード処理は トークンの強制 refresh(短命化または revocation) とセットで設計してください。

💡 「ティーザー戦略」:上位プラン専用ツールも _meta.required_plan 付きで露出し、isError: true で「このツールはProプランで利用できます。アップグレード: https://...」を返す設計も有効です。ユーザーに「Pro にすると何ができるか」を発見させられます。

4-3: 課金フックの3か所

リモートMCPでは、tools/call の前後 が課金計量の主要フックポイントです。

📌 エラーレスポンス層の判断基準(本記事の運用ポリシー)

状況 レスポンス層 形式
認証・認可レベルの拒絶(トークン無効・プラン期限切れ・scope不足) HTTP 401 / 403 + JSON-RPC error + WWW-Authenticate
トランスポート層の拒絶(レート超過・body過大・不正ホスト) HTTP 4xx + JSON-RPC error
ツール実行中の業務エラー(クォータ超過・パラメータ違反・外部API失敗) JSON-RPC 200 isError: true + content

判断基準は「AI クライアントが状況に応じて自己修正リトライする余地があるか」。業務エラー(quota / validation)はモデルが「クォータを使い切ったので別アプローチを試す」という思考に乗りやすいので isError: true で返す。認可エラーは人間の介入(再認証・アップグレード)が必要なので HTTP で明示的に弾く。

フック① ツール呼び出し前(gating)

server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
  const userId = extra.authInfo?.userId;
  const toolName = req.params.name;

  // クォータチェック
  const usage = await usageStore.getCurrentPeriod(userId);
  const quota = await quotaStore.getForUser(userId);

  if (usage.calls >= quota.maxCalls) {
    return {
      isError: true,
      content: [{ type: "text", text: "Monthly quota exceeded. Upgrade at https://..." }],
      _meta: {
        "example.com/quota_exceeded": true,
        "example.com/upgrade_url": "https://example.com/upgrade",
      },
    };
  }

  // ... ツール実行
});

フック② ツール呼び出し後(metering)

async function callToolWithMetering(req, extra, handler) {
  const start = Date.now();
  try {
    const result = await handler(req, extra);
    await meteringClient.record({
      userId: extra.authInfo?.userId,
      tool: req.params.name,
      durationMs: Date.now() - start,
      success: !result.isError,
      cost: TOOL_COSTS[req.params.name] ?? 1,
    });
    return result;
  } catch (err) {
    await meteringClient.record({
      userId: extra.authInfo?.userId,
      tool: req.params.name,
      durationMs: Date.now() - start,
      success: false,
      error: (err as Error).message,
    });
    throw err;
  }
}

// SDK ハンドラとの統合:tool 名で実装関数を引き、metering ラップして委譲
const toolHandlers: Record<string, (req: any, extra: any) => Promise<any>> = {
  search: runSearch, analyze: runAnalyze, /* ... */
};
server.setRequestHandler(CallToolRequestSchema, (req, extra) =>
  callToolWithMetering(req, extra, toolHandlers[req.params.name])
);

フック③ プラン期限切れ検知

// JWT の plan_expires_at が過ぎていたら 403 で「スコープ不足」を示す。
// ⚠️ RFC 6750 §3.1 の error 値は invalid_request / invalid_token / insufficient_scope の3つのみ。
//   プラン期限切れは「権限不足」なので insufficient_scope が意味論的に正しい。
//   invalid_token を返すとクライアントが「トークン失効」と解釈し、プラン更新導線に飛ばせない。
// 📌 JSON-RPC 2.0 仕様で -32000〜-32099 は「Server error」用に予約された自前空間。
//    本記事では下記の運用ポリシーで割り当てる(重複しないこと)。
//      -32000: レート制限(Part 3-4)
//      -32001: 認証必須(Part 2-3 WWW-Authenticate 401 / Part 2-4 invalid_token)
//      -32010: プラン期限切れ(本節:HTTP 403 で人間介入を促す)
//    クォータ超過などの業務エラーは「Part 4-3 エラーレスポンス層の判断基準」表に従い、
//    HTTP 200 + result.isError=true で返すため、JSON-RPC error コードは予約しない。
if (claims.plan_expires_at && claims.plan_expires_at * 1000 < Date.now()) {
  res.setHeader(
    "WWW-Authenticate",
    `Bearer error="insufficient_scope", scope="plan:pro", error_description="plan_expired"`
  );
  return res.status(403).json({
    jsonrpc: "2.0",
    error: {
      code: -32010,
      message: "plan_expired",
      data: { upgrade_url: "https://example.com/upgrade" },
    },
    id: req.body?.id ?? null,
  });
}

4-4: 課金プロバイダ抽象化レイヤ

Stripe / Paddle / Lemon Squeezy など、課金プロバイダは差し替え可能にしておくのが鉄則です。理由は3つ:

  1. プロバイダ買収・サービス停止リスク
  2. 地域別の最適プロバイダ切り替え(決済手数料・通貨・税)
  3. テスト時のモック差し替え

最小抽象化インターフェース

interface BillingProvider {
  recordUsage(input: { userId: string; tool: string; quantity: number; metadata?: Record<string, any> }): Promise<void>;
  getActivePlan(userId: string): Promise<{ plan: string; expiresAt: number } | null>;
  createCheckoutSession(input: { userId: string; planId: string; returnUrl: string }): Promise<{ url: string }>;
  cancelSubscription(userId: string): Promise<void>;
}

// 実装は Stripe / Paddle / モック を差し替え可能
class StripeBilling implements BillingProvider { /* ... */ }
class PaddleBilling implements BillingProvider { /* ... */ }
class MockBilling   implements BillingProvider { /* ... */ }

// DI で注入
const billing: BillingProvider = process.env.NODE_ENV === "test"
  ? new MockBilling()
  : new StripeBilling(process.env.STRIPE_KEY!);

⚠️ 本記事はインターフェース設計のみ扱います。各プロバイダの SDK 利用方法は公式ドキュメントを参照してください。Stripe 実装の詳細を扱う別記事を予定しています。

📌 プロバイダ非依存で必ず実装すべき2点

// (1) Webhook 受信時の冪等性:同じ event_id を2回処理しない
app.post("/webhooks/billing", async (req, res) => {
  const eventId = req.header("Idempotency-Key") ?? req.body?.id;
  if (await eventStore.exists(eventId)) {
    return res.status(200).send("duplicate"); // 既処理を 2xx で返す
  }
  await eventStore.markProcessing(eventId);
  // ... 業務処理
  await eventStore.markCompleted(eventId);
  res.status(200).send("ok");
});

// (2) Webhook 署名検証:プロバイダごとに方式は違うが、必ず raw body で検証
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

// HMAC-SHA256 + constant-time 比較(独自実装で必ず timingSafeEqual を使う)
function verifySignature(rawBody: Buffer, signature: string | undefined, secret: string): boolean {
  if (!signature) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  // ★ 文字列の === 比較は早期 return でタイミング攻撃の余地あり。必ず timingSafeEqual。
  const expBuf = Buffer.from(expected, "hex");
  const sigBuf = Buffer.from(signature, "hex");
  if (expBuf.length !== sigBuf.length) return false;
  return timingSafeEqual(expBuf, sigBuf);
}

app.post(
  "/webhooks/billing",
  express.raw({ type: "application/json" }),  // ★ raw body 必須
  (req, res) => {
    const signature = req.header("X-Provider-Signature");
    if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET!)) {
      return res.status(401).send("invalid signature");
    }
    // ...
  }
);

💡 Stripe / Paddle は専用検証ヘルパーを提供しているstripe.webhooks.constructEvent(rawBody, signature, secret) や Paddle の verifyWebhookSignature を使うのが安全です。独自実装は他プロバイダ・自前 webhook 用の参考に。

Stripe の最小実装(プロバイダ依存層の参考)

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_KEY!);

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),   // ★ raw body(json() より前に登録)
  async (req, res) => {
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,                                // ★ raw Buffer のまま渡す
        req.header("stripe-signature") ?? "",
        process.env.STRIPE_WEBHOOK_SECRET!,
      );
    } catch {
      return res.status(401).send("invalid signature");
    }

    // 冪等性チェック(Part 4-4 冒頭の eventStore と同じ)
    if (await eventStore.exists(event.id)) {
      return res.status(200).send("duplicate");
    }
    await eventStore.markProcessing(event.id);

    switch (event.type) {
      case "customer.subscription.updated": {
        const sub = event.data.object as Stripe.Subscription;
        const userId = sub.metadata.userId;
        const plan = sub.items.data[0].price.lookup_key ?? "free";
        await tokenStore.savePlan(userId, plan);
        // Part 4-2 のプラン変更通知(強制 refresh)と連動
        await notifyPlanChanged(userId);
        break;
      }
      case "customer.subscription.deleted":
        await tokenStore.savePlan((event.data.object as Stripe.Subscription).metadata.userId, "free");
        break;
    }

    await eventStore.markCompleted(event.id);
    res.json({ received: true });
  },
);

このコードは「Stripe → MCP の課金状態反映」の最低限の経路を示すもので、実運用では Webhook イベントの全網羅(invoice.paid / customer.subscription.trial_will_end / charge.refunded 等)と、各イベントに対する business logic を本記事の範囲外として別途設計してください。

⚠️ express.json() を webhook ルートにかけてはいけない:JSON パース後の object では署名検証用ハッシュが変わるため検証が落ちます。webhook 専用のルートだけ express.raw() を当ててください。

📌 app.use(express.json()) グローバル適用との競合:Express ミドルウェアは登録順に評価されるため、app.use(express.json())先に書くと、後段の express.raw() は効きません(既に JSON パース済みの body が渡る)。本記事の Part 1-1 はこの罠を避けて app.post("/mcp", express.json(), ...)ルート単位適用にしています。webhook ルートを足すときは (a) ルート単位適用を維持する、(b) もしくは webhook ルートを app.use(express.json()) より前に登録する、のいずれかを選んでください。

4-5: 無料枠と Free-tier abuse 対策

無料枠は新規ユーザーの入口として必要ですが、abuse 対策なしでは破綻します。

対策の重ね合わせ

  1. OAuth Client ID 単位の制限:同一クライアントから大量の Free アカウント作成を防ぐ
  2. メール検証必須:使い捨てメールドメインのブロックリスト併用
  3. クレジットカード登録必須(無料プランでも):abuse 心理的ハードル
  4. IPベースのレートリミット:認証前フェーズに特に重要
  5. Device Fingerprinting:Cloudflare Bot Fight Mode 等
  6. ツール単位のクォータ:高コストツールは Free 枠から除外
// Free ユーザーは高コストツールを `_meta.is_locked` 付きで露出
function isFreeQuotaExceeded(usage, plan) {
  if (plan !== "free") return false;
  return usage.calls >= FREE_TIER_MONTHLY_LIMIT;
}

💡 「Free → Paid のコンバージョン率」を測る:マネタイズの最重要 KPI です。Part 5 のモニタリングで usage.calls / quota_exceeded イベントを記録しておくと、後で「どのツールで上限に当たったユーザーがアップグレードしたか」を分析できます。


Part 5: デプロイ実装例 + 配布・運用

5-1: Cloudflare Workers で動かす最小実装

Cloudflare Workers はリモートMCPサーバーの主要なデプロイ先のひとつです。公式 agents/mcp パッケージは OAuth Provider 統合・Durable Objects によるセッション分離・Streamable HTTP の SSE ストリーム管理を提供しており、本記事の Part 1〜4 で扱った要件をほぼそのまま実装してくれます。

📌 Part 1〜4 のコードとの関係:本記事の Part 1〜4 は Node + Express を前提にしたコードで、Workers にはそのままコピペできません。対応関係は以下:

Part 1〜4 の概念 Node + Express での実装 Cloudflare Workers + agents/mcp での実装
セッション管理(Part 1-2) transports Map + onsessioninitialized McpAgent クラスが Durable Object として自動分離
OAuth Resource Server(Part 2) jose で JWT 検証ミドルウェア OAuthProvider@cloudflare/workers-oauth-provider)が代行
マルチテナント(Part 1-4) request-scoped 引数 / 外部ストア DO インスタンスごとの this.props で自動隔離
レート制限(Part 3-4) rate-limiter-flexible のメモリ実装 Workers Rate Limiting binding / Durable Objects 自前 / Upstash Redis
CORS / Origin 検証(Part 3-3) Express ミドルウェア Workers fetch handler の冒頭で検証

「概念は Part 1〜4、コードは Part 5-1 のスタックに翻訳」と読んでください。両者の脅威モデル・要件は共通です。

⚠️ API選択の最重要ポイントMcpAgent.mount()legacy API で内部的に SSE トランスポートを起動します。Streamable HTTP を使いたいなら 必ず McpAgent.serve(path)(default transport: "streamable-http")か createMcpHandler(server) を使ってください。新規実装で .mount() を選ぶ理由はありません。

wrangler.toml

name = "remote-mcp"
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]

[[kv_namespaces]]
binding = "OAUTH_KV"
id = "<your-kv-id>"

# McpAgent サブクラスそのものが Durable Object。
# binding 名は McpAgent.serve() の default である "MCP_OBJECT" に合わせる。
[[durable_objects.bindings]]
name = "MCP_OBJECT"
class_name = "MyMcp"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyMcp"]

[vars]
ALLOWED_ORIGINS = "https://claude.ai,https://chatgpt.com"

src/index.ts(最小構成・McpAgent クラスパターン)

import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
import { z } from "zod";
import { AuthHandler } from "./auth-handler";

// OAuth Provider が認証成功時に詰めるユーザー情報の型
// ⚠️ 不変属性のみを置くのが原則(変動属性は後述「📌 hibernation 問題への具体策」を参照)。
//   `plan` は変動するが、init() でツールを分岐したい都合で props にも入れる
//   ── プラン変更時はトークンの強制 refresh + DO 再起動で同期する。
type PlanName = "free" | "pro" | "team" | "enterprise";

type Props = {
  userId: string;
  username: string;
  email: string;
  plan: PlanName;
};

type Env = {
  OAUTH_KV: KVNamespace;
  MCP_OBJECT: DurableObjectNamespace;
  ALLOWED_ORIGINS: string;
};

export class MyMcp extends McpAgent<Env, unknown, Props> {
  server = new McpServer({ name: "remote-mcp-demo", version: "1.0.0" });

  async init() {
    this.server.registerTool(
      "echo",
      {
        description: "Echo back input text",
        inputSchema: { text: z.string() },
      },
      async ({ text }) => ({
        // this.props.userId に認証済みユーザーIDが入っている
        content: [{ type: "text", text: `[${this.props.userId}] ${text}` }],
      })
    );
  }
}

export default new OAuthProvider({
  apiRoute: "/mcp",
  apiHandler: MyMcp.serve("/mcp"),       // ★ .serve() で Streamable HTTP(default)
  defaultHandler: AuthHandler,           // ログイン UI を提供する Hono アプリ等
  authorizeEndpoint: "/authorize",
  tokenEndpoint: "/oauth/token",
  clientRegistrationEndpoint: "/oauth/register",  // DCR を許可するなら
});

AuthHandler/authorize の GET/POST で承認フローを実装する Hono などのアプリです。Cloudflare 公式リポジトリの examples/mcp-worker-authenticated にコピペ可能なテンプレートがありますが、最小骨子は以下です。

// src/auth-handler.ts
import { Hono } from "hono";
import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider";

type Bindings = { OAUTH_PROVIDER: OAuthHelpers };
const app = new Hono<{ Bindings: Bindings }>();

// 1. ログイン画面の表示
app.get("/authorize", async (c) => {
  const authReq: AuthRequest = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
  // 同意画面の HTML を返す(form action は POST /authorize)
  return c.html(`
    <form method="POST" action="/authorize">
      <input type="hidden" name="state" value="${authReq.state}">
      <input name="email" type="email" required>
      <input name="password" type="password" required>
      <button>許可</button>
    </form>
  `);
});

// 2. ログイン送信を受けて Authorization Code を発行
app.post("/authorize", async (c) => {
  const form = await c.req.formData();
  const user = await authenticateUser(form.get("email"), form.get("password"));
  if (!user) return c.text("Unauthorized", 401);

  // OAuthProvider に「ユーザーを認証した」と通知し、props を MCP 側に渡す
  const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
    request: await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw),
    userId: user.id,
    metadata: { label: user.email },
    scope: ["mcp:tools", `plan:${user.plan}`],
    props: { userId: user.id, username: user.email, email: user.email, plan: user.plan },
  });
  return c.redirect(redirectTo);
});

export const AuthHandler = app;

⚠️ 本番では authenticateUser を IdP(Auth0 / Cognito / 自前 RDB + Argon2id)に置き換えてください。上記は OAuth Provider の completeAuthorization を呼ぶ最小構造の例示です。

💡 McpAgent.props がマルチテナントの肝:OAuth Provider が認証成功時に completeAuthorization({ ..., props: userProfile }) で渡した値が、McpAgent サブクラスの this.props に届きます。Durable Object 単位で隔離されるため Part 1-4 で扱った「プロセス変数禁止」が自動的に守られます。

💡 McpAgent.props と外部ストア(prefsStore)の使い分けprops は OAuth 認証フロー時に確定する不変属性(userId / username / email)に向きます。プラン情報・利用量・ユーザー設定など変動する属性は Part 1-4 で示した外部ストア経由で取得してください。props は Durable Object hibernation で永続化されるため、認証時点の古い値が固着するリスクがあります。

📌 hibernation 問題への具体策:プラン情報を props に入れたい誘惑に負けると、ダウングレード直後でも DO がコールドスタートするまで古いプランで動き続けます。次の3点で防ぎます。

  1. init() ではなく tools/list / tools/call ハンドラ内で外部ストア参照:呼び出しのたびに最新プランを取得(キャッシュは TTL 60 秒程度)
  2. プラン変更時に DO を強制再起動:Cloudflare の場合 state.storage.deleteAll() または明示的に DO 識別子を変更
  3. トークンの短命化:Access Token を 15 分以下にしておけば、refresh のたびに最新プランが claims に反映される

プラン別ツール登録(Part 4-2 と Part 5-1 の連携)

📌 原則は「ハンドラ内動的フィルタ」:プラン情報は変動属性のため、tools/list ハンドラ内で呼び出しのたびに最新プランを取得し、その結果でフィルタするのが最も安全です。Part 4-2 と同じパターンを McpAgent 上でもそのまま使えます。

import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

export class MyMcp extends McpAgent<Env, unknown, Props> {
  server = new McpServer({ name: "remote-mcp-demo", version: "1.0.0" });

  async init() {
    // ★ ハンドラ「内」でフィルタするため、init() では全ツールを登録する
    this.server.registerTool("search",      { /* ... */ }, runSearch);
    this.server.registerTool("analyze",     { /* ... */ }, runAnalyze);
    this.server.registerTool("bulk_export", { /* ... */ }, runBulkExport);

    // tools/list の応答時に外部ストアで最新プランを引き直してフィルタ
    // ⚠️ McpServer.server は McpServer が内部で保持する基底 Server インスタンスへの
    //   公開プロパティ(SDK v1.29.x で動作確認済み)。SDK のメジャーバージョン更新時は
    //   型定義で .server プロパティの存在を確認し、なければ registerTool ベースに書き直す。
    this.server.server.setRequestHandler(ListToolsRequestSchema, async () => {
      const plan = await prefsStore.get(`plan:${this.props.userId}`)
        ?? this.props.plan;                                    // フォールバック
      return {
        tools: ALL_TOOLS
          .filter((t) => isPlanSufficient(String(plan), t.requiredPlan))
          .map((t) => ({ ...t, _meta: { "example.com/current_plan": plan } })),
      };
    });
  }
}

💡 init() 内でプラン分岐する最適化パターン(プラン変更頻度が極端に低い場合のみ):上のフィルタロジックは毎回ストア参照するため、低頻度プラン変更のサービスでは「init() 時にプラン分岐して registerTool を絞る」ほうが軽量です。ただし hibernation で古い props が固着するリスクがあるため、必ず Part 4-2 の tools/list_changed 通知+トークン強制 refresh とセットで運用します。

// 最適化版:init() でプラン分岐
async init() {
  this.server.registerTool("search", { /* ... */ }, runSearch);
  if (PLAN_HIERARCHY.indexOf(this.props.plan) >= PLAN_HIERARCHY.indexOf("pro")) {
    this.server.registerTool("analyze", { /* ... */ }, runAnalyze);
  }
  if (PLAN_HIERARCHY.indexOf(this.props.plan) >= PLAN_HIERARCHY.indexOf("team")) {
    this.server.registerTool("bulk_export", { /* ... */ }, runBulkExport);
  }
}

⚠️ どちらを選ぶか:プラン変更が日次・週次なら前者(ハンドラ内フィルタ)、月次以下なら後者(init() 分岐) が現実解です。後者を採用する場合は、プラン変更イベントで DO を強制再起動するフックを別途用意してください。

5-1-2: Cloudflare Workers の実運用制約

「Cloudflare で動く」と書いた以上、Workers 固有の制約に触れておきます。

制約 無料プラン 有料プラン(Workers Paid)
CPU 時間/リクエスト 10ms 30s(設定可、default 30s)
メモリ 128MB 128MB
サブリクエスト数 50 1000
リクエスト body サイズ 100MB 500MB
HTTP 接続維持 100秒(自動切断) 100秒(自動切断)

⚠️ 重い LLM 呼び出し・長時間処理の MCP は Workers 単体では成立しない:例として 30 秒を超える OpenAI Streaming や、5000 ファイルのバルク取得などは制約に当たります。

📌 長時間処理の逃がし先 4 択

選択肢 適した処理 制限
Workflows 状態遷移を持つマルチステップ処理(リトライ・分岐) ステップ間で待機可能。タスク粒度の設計が必要
Queues 非同期 fire-and-forget(メール送信・後追い集計) 順序保証は弱い・最大 30 秒/メッセージ
Durable Objects セッションを保ったまま分割実行(チャットの長文応答) DO は単一インスタンスで直列化されるので並列度は出ない
Containers(2026年 GA) 既存 Node / Python の重量級ジョブ・GPU 推論 コスト高・起動レイテンシ秒オーダー

判断基準:ユーザー応答に同期で返すなら Workflows(進捗を SSE で送る)、応答後に裏で動かすなら Queues、既存のフルスタック資産を持ち込むなら Containers。

⚠️ GET /mcp の SSE ストリームは「無通信 100 秒」で切れる:Cloudflare の Free/Pro プランは HTTP 接続が 100 秒無通信になると 524 タイムアウトで切断します(Cloudflare Fundamentals: Connection limits)。Workers 自体には SSE 応答時間の上限はないため、サーバー側で定期的に SSE コメント行(:keep-alive\n\n)を 15〜30 秒間隔で送れば idle timeout は回避できます。

⚠️ Workers の setInterval/setTimeout は hibernation で消失Cloudflare 公式 DO ライフサイクル で明示されている通り、Durable Object が hibernate / evict されると 進行中のタイマーは破棄され、コールバックは呼ばれません(通常の Worker fetch handler 内でも、レスポンス返却後の isolate 破棄で同様)。正規パターンは Durable Object Alarm API:永続化された alarm 時刻に基づいて DO が起き直り、alarm() ハンドラが at-least-once 実行されます。

// SSE keep-alive を Durable Object Alarm で実装する正規パターン
export class McpStreamSession {
  state: DurableObjectState;
  writer?: WritableStreamDefaultWriter<Uint8Array>;

  constructor(state: DurableObjectState) {
    this.state = state;
  }

  async startStream(writer: WritableStreamDefaultWriter<Uint8Array>): Promise<void> {
    this.writer = writer;
    // 20 秒後に alarm() を起動(Cloudflare 内部で永続化される)
    await this.state.storage.setAlarm(Date.now() + 20_000);
  }

  // ★ DO が hibernation から復活してもこのハンドラは確実に呼ばれる
  async alarm(): Promise<void> {
    if (!this.writer) return;
    try {
      await this.writer.write(new TextEncoder().encode(":keep-alive\n\n"));
      // 次の keep-alive を予約してループ
      await this.state.storage.setAlarm(Date.now() + 20_000);
    } catch {
      // クライアント切断で writer.write が throw → alarm 連鎖を停止
      this.writer = undefined;
    }
  }
}

💡 agents/mcp を使う場合McpAgent クラスは内部で Durable Object として動作し、SSE ストリーム維持に必要な keep-alive を自動で挿入します。上記の手書き alarm パターンは「agents/mcp を使わずに自前で WorkerTransport を実装する」ケース向けの参考です。

ただし、ネットワーク切断・クライアント再起動による接続喪失は keep-alive では救えないため、クライアント側は Last-Event-ID ヘッダによる resumption を併用するのが堅実です(SDK が対応済み)。サーバー側は各イベントに単調増加 id: を付与し、Last-Event-ID 受信時に未送信分から再送する実装にします。

5-1-3: ローカル開発のセットアップ

# 1. Wrangler 起動(自動リロード付き)
npx wrangler dev --local

# 2. localhost をリモートMCPとして見せる:ngrok / cloudflared でトンネル
cloudflared tunnel --url http://localhost:8787

# 3. Claude.ai (Web) からトンネルURL(例:https://xxx.trycloudflare.com/mcp)を登録

⚠️ localhostHost ヘッダのまま Claude.ai に登録すると DNS Rebinding 対策で 403:本記事 Part 3-3 で示した ALLOWED_HOSTS にはトンネル経由で来る Host ヘッダ(cloudflared なら xxx.trycloudflare.com)を追加してください。トンネルURLが変わるたびに足す手間を減らすなら、process.env.TUNNEL_HOST で動的注入する形が現実的です。

5-2: 公式 Registry への登録

2025-09-08 にプレビュー公開された MCP 公式 Registryregistry.modelcontextprotocol.io)は、Anthropic / GitHub / PulseMCP の各 Registry Maintainers が運営するメタレジストリです。Smithery / Glama などのサブレジストリも上流参照を始めているため、公開MCPは公式 Registry への登録が標準的な経路になりつつあります。

登録に必要なもの

  • DNS TXT レコードでのドメイン所有検証 または GitHub OIDC
  • server.json のメタデータ(name, version, transport, capabilities)
  • 利用条件・サポート連絡先

💡 $schema URL についてhttps://modelcontextprotocol.io/schemas/server.json は 2025-09 プレビュー段階のスキーマ URL です。GA 後に変更される可能性があるため、登録前に registry.modelcontextprotocol.io で最新 URL を確認してください。

// server.json
{
  "$schema": "https://modelcontextprotocol.io/schemas/server.json",
  "name": "com.example/remote-mcp",
  "version": "1.0.0",
  "description": "Example remote MCP server",
  "transport": {
    "type": "streamable_http",
    "url": "https://mcp.example.com/mcp"
  },
  "auth": {
    "type": "oauth2",
    "metadata_url": "https://mcp.example.com/.well-known/oauth-protected-resource"
  },
  "homepage": "https://example.com/mcp",
  "license": "MIT"
}

💡 登録後の影響:公式 Registry に載ると Smithery / Glama にも自動で反映され、mcp-scan / snyk-agent-scan の評価対象にもなります。Verified バッジを取得するなら早めに登録しておくのが有利です。

ドメイン所有検証(DNS TXT 方式)の最小手順

⚠️ 以下は仮想コマンド例です@modelcontextprotocol/publisher は 2026-05 時点でプレビュー段階のため、パッケージ名・コマンド引数・動作は変更される可能性があります。実際の登録手順は registry.modelcontextprotocol.io の最新ドキュメントで確認してください。

# 1. Registry CLI(publisher.modelcontextprotocol.io が配布する想定)
npx @modelcontextprotocol/publisher login
# → ブラウザでログインしてアカウント連携

# 2. challenge を取得
npx @modelcontextprotocol/publisher domain:claim mcp.example.com
# → "_mcp-registry-challenge.mcp.example.com TXT 'verify=abc123...'" と表示

# 3. DNS に上記 TXT レコードを追加(Cloudflare / Route 53 / Gandi 等)

# 4. 検証
npx @modelcontextprotocol/publisher domain:verify mcp.example.com

# 5. server.json をアップロード
npx @modelcontextprotocol/publisher publish ./server.json

GitHub OIDC 方式の GitHub Actions ワークフロー例(以下も仮想コマンド例。上記と同様に要確認)

# .github/workflows/publish-mcp.yml
name: Publish MCP server to registry
on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # ★ OIDC 必須
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - name: Publish to MCP registry
        run: npx @modelcontextprotocol/publisher publish ./server.json --auth=github-oidc

⚠️ CLI / API 名は変更されうる:MCP Registry は 2025-09 プレビュー公開段階。コマンド名や API 形式は GA に向けて変更される可能性があるため、登録直前に registry.modelcontextprotocol.io で最新ドキュメントを確認してください。

5-3: モニタリング

OWASP MCP08(Lack of Audit and Telemetry)対策として、次の指標を必ず取ります。

指標 目的 推奨収集ツール
ツール呼び出し回数(per user / per tool / per tenant) クォータ管理・課金 OpenTelemetry / Datadog
レイテンシ p50/p95/p99 UX 監視 同上
エラー率(プロトコル / ツール実行) 障害検知 同上
isError: true AI の自己修正成功率 同上
認証失敗回数 abuse 検知 同上
Mcp-Protocol-Version 分布 クライアント更新追跡 同上
トークン失効イベント セキュリティ監査 SIEM

OpenTelemetry 例(最小)

// 1. SDK 初期化(プロセス起動時に1回だけ)
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_TRACES_ENDPOINT }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: process.env.OTLP_METRICS_ENDPOINT }),
    exportIntervalMillis: 30_000,
  }),
});
sdk.start();
// Datadog / Grafana Cloud / Honeycomb は OTLP エンドポイントを公開しており、
// URL とトークンを環境変数に入れるだけで連携できる。

// 2. 計測コード(リクエスト処理内で取得)
import { trace, metrics } from "@opentelemetry/api";

const tracer = trace.getTracer("remote-mcp");
const meter = metrics.getMeter("remote-mcp");
const toolCallCounter = meter.createCounter("mcp.tool.calls");
const toolDuration = meter.createHistogram("mcp.tool.duration_ms");

server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
  const span = tracer.startSpan(`tool:${req.params.name}`);
  // ⚠️ 高カーディナリティ属性(user_id / session_id / request_id)はメトリクスに乗せない。
  //   OTel ベストプラクティス:基数爆発でストレージとコストが破綻するため、
  //   user_id は span attribute / structured log に置く。
  // 💡 extra.sessionId は Streamable HTTP 専用(stdio では undefined)。
  //   後編コードを流用するなら ?? "" でガードしておく。
  span.setAttribute("mcp.user_id", extra.authInfo?.userId ?? "anonymous");
  span.setAttribute("mcp.session_id", extra.sessionId ?? "");

  const start = Date.now();
  try {
    // ★ Part 4-3 の toolHandlers dispatch map と組み合わせる:
    //   const toolHandlers = { search: runSearch, ... };
    const result = await toolHandlers[req.params.name](req, extra);
    // メトリクス attribute は低カーディナリティのみ(tool 名・成否・プランなど有限集合)
    toolCallCounter.add(1, {
      tool: req.params.name,
      success: String(!result.isError),
      plan: String(extra.authInfo?.claims?.plan ?? "free"),
    });
    return result;
  } finally {
    toolDuration.record(Date.now() - start, { tool: req.params.name });
    span.end();
  }
});

⚠️ ログのシークレット漏洩:OWASP MCP01 の典型的ハマりポイントです。ログ出力前に必ず redact を通します(Pino の redact オプション、structlog のフィルタなど)。

📌 監査ログの必須フィールド

フィールド 用途 注意
timestamp (RFC 3339, UTC) 時系列突合 クライアント時刻ではなくサーバー時刻
userId per-user 追跡 sub クレーム
sessionId セッション単位の流れ追跡 Mcp-Session-Id
traceId 分散トレース突合 OpenTelemetry の trace_id
toolName 何が呼ばれたか
inputHash 入力同定(再現可能性) 平文ではなく SHA-256 ハッシュ。個人情報の二重保管を避ける
outputHash 出力同定 同上
success / errorCode 失敗集計
clientId OAuth クライアント識別 abuse 分析
ip abuse 分析 GDPR 上は個人情報扱い。保持期間に注意
region 地理分布 IP より粒度は荒いが個人特定に貢献しうる。GDPR 上は注意

📌 保持期間とプライバシー:EU ユーザーを含む場合、IP・userId を含むログは 目的に応じた最短期間にします(不正検知なら 90 日、課金監査なら 7 年など)。GDPR の **削除要求(Art. 17)**への対応として、userId 単位で論理削除できる構造にしておきます。

📌 SIEM 連携の選択肢

SIEM 連携経路 向いているケース
Datadog Audit Trail OTel Collector or 直接 API アプリ監視と統合
Splunk Cloud HEC(HTTP Event Collector) エンタープライズ既存 SIEM
Elastic Security / OpenSearch Fluent Bit / Vector 経由 OSS 志向
Cloudflare Logpush R2 / Splunk / Datadog へ自動転送 Workers 環境
AWS Security Lake OCSF 形式で集約 AWS 顧客の規制対応

💡 Workers なら Logpush + R2 が最速で始められるwrangler.toml[observability] で有効化、fetch ログ・console.log 出力が自動で R2 / 外部 SIEM に転送されます。ただしシークレットの redact は自分でやる必要があります。

5-3-2: DDoS / Slow-Loris 対策

リモートMCPは公開エンドポイントなので、意図的な攻撃や bot による無差別スキャンにさらされます。Part 3-4 のレート制限とは別軸で、ネットワーク層の対策が必要です。

📌 多層防御

対策 実装場所
L7 DDoS(HTTP flood) Cloudflare WAF / Bot Fight Mode Cloudflare ダッシュボード
Slow-Loris(接続滞留) 受信タイムアウト 30 秒 / 同時接続数上限 リバースプロキシ(nginx / Cloudflare)
超大 body 攻撃 Content-Length の事前拒否 アプリ層
JSON 爆弾(深いネスト) パース深さ・サイズ制限 アプリ層
Zip Bomb(gzip 圧縮入力) 展開後サイズ上限 アプリ層
// Content-Length の事前拒否(巨大 body の前段カット)
const MAX_BODY_BYTES = 1_000_000;  // 1MB
app.use((req, res, next) => {
  const len = parseInt(req.header("Content-Length") ?? "0", 10);
  if (len > MAX_BODY_BYTES) {
    return res.status(413).json({
      jsonrpc: "2.0",
      error: { code: -32600, message: "Request body too large" },
      id: null,
    });
  }
  next();
});

// express.json() のサイズ・深さ制限
app.use(express.json({
  limit: "1mb",
  // depth 制限は JSON.parse には無いため、独自バリデーション or zod の z.lazy で対応
}));

⚠️ Workers では Content-Length を信用しすぎない:HTTP/2 / gzip 圧縮時は Content-Length が無いか異なる値になることがあります。実際に読んだバイト数で再判定するロジックも併用してください。

5-4: バージョニング戦略

tools.listChanged 通知でクライアントに「ツール定義が変わった」と知らせられますが、破壊的変更の影響範囲がローカルとは桁違いな点に注意。

5-4-1: 破壊的変更チェックリストと DEPRECATED 表記

📌 破壊的変更チェックリスト

変更内容 破壊性 推奨対応
ツール追加 非破壊 そのまま tools/list_changed
ツールに optional パラメータ追加 非破壊 そのまま
ツールに required パラメータ追加 破壊的 新ツール名にする(tool_v2
ツール名変更 破壊的 DEPRECATED 表記の旧ツールを残す
ツール削除 破壊的 最低3か月の DEPRECATED 期間
レスポンス形式変更 破壊的 outputSchema の変更は新ツール扱い
スコープ要件追加 破壊的 プラン変更フローと連動

DEPRECATED 表記のパターン

⚠️ MCP SDK には server.callTool() の公開 API は存在しない:新旧ツール間で実装を共有したい場合、registerTool のハンドラとは別に 共通の実装関数を切り出して両方から呼ぶのが正しいパターンです。「旧ツールから新ツールへの自動転送」は SDK の関数呼び出しではなく、共通実装の再利用として表現します。

// 1. 実装本体を切り出し(純粋関数)
async function runAnalyze(args: AnalyzeArgs, ctx: ToolContext) {
  // 実際の解析処理
  return { content: [{ type: "text" as const, text: "..." }] };
}

// 2. 新ツールはそのまま登録
this.server.registerTool(
  "analyze_v2",
  {
    description: "Analyze with the v2 schema.",
    inputSchema: AnalyzeV2Schema,
    annotations: { readOnlyHint: true },
  },
  // this.ctx は Cloudflare Durable Object の ExecutionContext(waitUntil 等を提供)
  async (args) => runAnalyze(args, this.ctx)
);

// 3. 旧ツールは DEPRECATED 表記 + 同じ実装に委譲(必要なら引数を v2 へ変換)
this.server.registerTool(
  "analyze_old",
  {
    description: "[DEPRECATED 2026-08-01 廃止予定] このツールは analyze_v2 に置き換わりました。新規実装は analyze_v2 を使用してください。",
    inputSchema: AnalyzeOldSchema,
    annotations: { readOnlyHint: true },
  },
  async (args) => runAnalyze(adaptOldToV2(args), this.ctx)
);

💡 「同じハンドラに同じプロセス内ループバックで送る」のは絶対に避けるnew Client() でループバックすると SSE/transport を二重消費し、課金・テレメトリも二重カウントになります。あくまで実装関数の共有か、server.server.request() 経由の内部 RPC(SDK のプライベート API ですが安定しています)に限定してください。

5-4-2: テスト戦略(リモートMCP特有)

Part 1-6 で扱ったテナント越境テストに加え、リモートMCPでは以下のテストレイヤを揃えます。

レイヤ 対象 ツール例
ユニット(JWT 検証) aud/iss/exp/署名の各失敗パターン jest + jose の手動署名
統合(OAuth フロー全体) authorization_code 取得 → token 交換 → MCP 呼び出し Playwright(同意画面 UI まで)
ファズ(プロトコル耐性) 不正 JSON-RPC・巨大 body・不正ヘッダ OSS-Fuzz / jsonpolyglot
負荷(同時接続・スループット) 1000 セッション同時接続・p99 レイテンシ k6 / Artillery
カオス(部分障害) AS 障害・KV 障害・DO レプリカ遅延 toxiproxy / Chaos Mesh
// JWT 偽造の単体テスト(jose で手動署名して検証経路を網羅)
import { SignJWT, generateKeyPair } from "jose";

test("rejects token with wrong aud", async () => {
  const { privateKey } = await generateKeyPair("ES256");
  const wrongAud = await new SignJWT({ sub: "user-a" })
    .setProtectedHeader({ alg: "ES256" })
    .setAudience("https://evil.example.com")  // ★ 期待値と違う
    .setIssuer("https://auth.example.com")
    .setExpirationTime("1h")
    .sign(privateKey);
  await expect(verifyToken(wrongAud)).rejects.toThrow();
});

📌 負荷テストで見るべき4指標:(1) p99 レイテンシ、(2) tools/list のキャッシュヒット率、(3) DO の hot-spot(特定 userId への集中)、(4) OAuth /token のスループット。本番デプロイ前に想定ユーザー数の3倍で空負荷を回しておくと、本番でのスケール想定が現実的になります。

5-4-3: 配信構成(Multi-region / Federation)

グローバル展開時は次の3軸で設計します。

設計判断
AS のリージョン 単一リージョン集中 vs 地理レプリカ。JWKS は CDN 配信で全エッジから取得可能にする
RS のリージョン Cloudflare Workers は自動マルチリージョン。自前 VPS なら GeoDNS で振り分け
データの所在 EU ユーザーのデータは EU リージョンに固定(GDPR の transfer 要件)
セッションストア KV / DO のグローバル整合性は eventual。strong consistency が要るなら同一リージョン固定

⚠️ AS の地理レプリカは難易度が高い:トークン発行・revocation の整合性が破れると、リージョン A で revoke したトークンがリージョン B でしばらく使えてしまう事故が起きます。signing key と revocation list は強整合ストア(Postgres + リードレプリカ)に置くのが安全策です。

5-4-4: コスト見積もり(典型ケース)

「月数千円〜」では設計判断ができないので、典型シナリオで実額を試算します。

⚠️ 2026-05時点の Cloudflare Workers 料金構造公式 Pricing):

  • Workers Free:10万 req/日(≒ 月300万)まで無料。Durable Objects は使用不可
  • Workers Paid(Standard)月額 $5 の基本料+10M req/月込み、超過分 $0.50/M req
  • DO は Paid プラン必須。Storage は $5 ベースに 1GB-月込み

シナリオAは DO を使う以上、Workers Paid($5/月)を起点に計算します。

📌 シナリオA:個人 OSS(月100ユーザー・10万呼び出し/月)

項目 サービス 月額
Workers Paid 基本料 Cloudflare $5
Workers リクエスト 10万 req(無料枠 10M 内) $0
KV 読み書き Cloudflare(10万 ops) $0(無料枠内)
Durable Objects Cloudflare(最小利用) $0〜$1
トークン DB Supabase 無料枠 $0
ドメイン Cloudflare Registrar 約 $1/月($10/年)
小計 約 $6〜$7/月

📌 シナリオB:商用 SaaS(月1000ユーザー・1000万呼び出し/月)

項目 サービス 月額
Workers Paid 基本料 Cloudflare(10M req 込み) $5
Workers リクエスト超過 0(1000万 = 含まれる枠内) $0
Durable Objects 1000万呼び出し + 1GB-月 $4
KV / R2 1GB ストレージ + 1000万 reads $3
トークン DB(Postgres) Supabase Pro $25
Stripe 決済手数料 売上の 3.6% 売上連動
監査ログ(R2 / Logpush) 100GB ログ $1.5
小計 約 $40/月 + 決済手数料

💡 人件費・運用工数は別計上:上記はインフラ実費のみ。商用 SaaS では実際にはサポート・課金問い合わせ・セキュリティ運用に人月コストがかかります(個人 OSS なら工数のみ、SaaS なら月数万〜数十万円相当の工数を見込むのが現実的)。

📌 シナリオC:エンタープライズ(月1万ユーザー・1億呼び出し/月)

項目 サービス 月額
Workers + DO + KV + R2 Cloudflare Enterprise $200〜$500
トークン DB(Postgres マネージド) RDS / Cloud SQL HA 構成 $300
SIEM(Datadog / Splunk) 100GB/日 ログ $500〜$1500
AS(Auth0 Enterprise / Okta) 1万 MAU $500〜$2000
小計 約 $1500〜$4000/月

💡 Auth0 / Okta を選ぶか自前 AS を作るか:AS は OAuth 仕様の遵守責任が重く、セキュリティインシデント時の影響範囲も最大級。月1万ユーザー以下なら自前、それ以上は外部 IDPが現実解です。

📌 認可サーバーの選択肢(マネージド vs OSS セルフホスト)

種別 候補 月額目安(1万 MAU) 向いているケース
マネージド SaaS Auth0 / Okta / Cognito / Firebase Auth $500〜$2000 短納期・コンプラ証憑(SOC 2)も外注
OSS セルフホスト Keycloak / Authentik / Ory Hydra / Zitadel サーバー実費のみ($30〜$200) コスト圧縮・規制業種でデータ国内保持
クラウドネイティブ AWS Cognito / GCP Identity Platform $200〜$800 クラウド依存度が既に高い

⚠️ OSS でも運用工数は無料ではない:Keycloak / Ory Hydra は OAuth 2.1 / OIDC 準拠のフル機能 AS だが、バージョン更新・パッチ追従・スケール設計・JWKS ローテーション運用は自前。実費は安くても運用工数 1〜2 人月/年は見込むのが現実的です。

5-4-5: 法務・契約・SLA(マネタイズ章の続き)

エンタープライズに売る場合、技術以外の整備項目が多数発生します。

項目 内容
利用規約(ToS) サービス停止条件・禁止行為・知的財産権
プライバシーポリシー データ収集範囲・第三者提供・保持期間
DPA(Data Processing Agreement) GDPR Art. 28 準拠の処理者契約
サブプロセッサ開示 Cloudflare / Stripe / Auth0 等の連鎖開示
SLA(Service Level Agreement) uptime 99.9% / レイテンシ p95 / RTO・RPO
セキュリティ証憑 SOC 2 Type II / ISO 27001 / Pentest レポート
データ越境同意 米国・EU 間データ転送(DPF / SCC)

⚠️ OSS 出身の開発者が見落としがち:「コードは MIT で公開してるから契約不要」は SaaS 提供では成立しません。ホスティングしている時点でデータ処理者としての義務が発生します。

5-4-6: GDPR Art. 20(データポータビリティ)

削除権(Art. 17)に加え、ユーザーが自分のデータを 構造化・機械可読な形式でエクスポートする権利があります。

app.get("/api/users/me/export", requireAuth, async (req, res) => {
  const userId = req.authInfo!.userId;
  const data = {
    profile: await prefsStore.get(`profile:${userId}`),
    usage: await usageStore.getAll(userId),
    sessions: await sessionStore.findByUserId(userId),
    audit_log: await auditStore.findByUserId(userId, { limit: 10000 }),
    exported_at: new Date().toISOString(),
    format_version: "1.0",
  };
  res.setHeader("Content-Disposition", `attachment; filename="export-${userId}.json"`);
  res.json(data);
});

💡 エクスポートは「ユーザー認証が完了している現在のリクエスト」で同期実行できる規模なら同期、大規模なら非同期ジョブ + メール通知にします。後者の場合、ダウンロード URL は短命の署名付きで発行します。

5-4-7: ツール定義の i18n / 多言語対応

リモートMCPは全世界配信前提なので、ツール description の多言語化が重要です。

const TOOL_DESCRIPTIONS: Record<string, Record<string, string>> = {
  analyze: {
    "en": "Analyze the input text and return sentiment.",
    "ja": "入力テキストを分析して感情を返します。",
    "zh-CN": "分析输入文本并返回情感。",
  },
};

function localize(toolName: string, locale: string): string {
  const dict = TOOL_DESCRIPTIONS[toolName] ?? {};
  // 優先順位: locale 完全一致 → 言語のみ → en(fallback)
  return dict[locale]
    ?? dict[locale.split("-")[0]]
    ?? dict["en"]
    ?? toolName;
}

server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
  const locale = extra.authInfo?.claims?.locale ?? "en";
  return {
    tools: ALL_TOOLS.map((t) => ({
      ...t,
      description: localize(t.name, String(locale)),
    })),
  };
});

💡 Accept-Language ヘッダではなくクライアントの _meta.locale を優先:MCP クライアント(Claude Desktop 等)は OS ロケールを _meta に詰めて送るのが慣例化しつつあります。Accept-Language は HTTP 経由でしか入らないので、リモートMCP特有の経路。

5-4-8: Tool Annotations のリモート意味論

readOnlyHint / destructiveHint / idempotentHint / openWorldHint の各 hint は、リモート × マルチテナント × 課金の文脈で意味が増します。

Annotation リモート特有の意味
readOnlyHint: true Free プランで開放しやすい・キャッシュしやすい・監査ログのプライバシー扱いが軽い
destructiveHint: true prompt=consent 強制・MFA 再要求・課金確認画面の挿入候補
idempotentHint: true Webhook 再送に安全・課金 metering の重複排除を緩められる
openWorldHint: true 外部 API 呼び出しのコスト計上・データ越境の検査対象

⚠️ annotations はクライアント側の「ヒント」であって強制力はない:リモートMCPなら サーバー側で実際にゲーティングを実装してください。destructiveHint: true のツールは server-side で「直近 5 分以内に MFA 認証された claim を要求」する形で実効性を持たせます。

5-4-9: Disaster Recovery 戦略

観点 目標 実装
RPO(Recovery Point Objective) 1時間以内のデータ損失 課金DB は同期レプリカ + WAL バックアップ
RTO(Recovery Time Objective) 1時間以内の復旧 IaC 化(Terraform)でリージョン切替
暗号化鍵の世代管理 鍵漏洩時の即時 rotate KMS の自動 rotation + 旧鍵の grace period
バックアップ検証 月1回のリストア演習 CI で定期的に空リージョンへリストア

⚠️ 「Cloudflare Workers が落ちることは無い」は誤り:Cloudflare 全体障害(2022-06、2023-11 等)は実在します。RS の主系を Cloudflare、災害復旧系を別クラウド(AWS / GCP)に置く構成も検討対象です。

5-4-10: response_modes / iss パラメータ(OAuth 2.1 SHOULD)

PRM / AS metadata で開示すべき項目に2つ補足があります。

app.get("/.well-known/oauth-authorization-server", (req, res) => {
  res.json({
    issuer: "https://auth.example.com",
    authorization_endpoint: "https://auth.example.com/authorize",
    token_endpoint: "https://auth.example.com/oauth/token",
    response_modes_supported: ["query", "fragment"],  // ★ OAuth 2.1 SHOULD
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256"],       // ★ PKCE 必須
    authorization_response_iss_parameter_supported: true,  // ★ RFC 9207
    revocation_endpoint: "https://auth.example.com/oauth/revoke",  // ★ RFC 7009
    introspection_endpoint: "https://auth.example.com/oauth/introspect",  // ★ RFC 7662
  });
});

💡 iss パラメータ(RFC 9207):認可応答に iss を含めることで、クライアントが「どの AS から返ってきた応答か」を検証できます。複数 AS をサポートするクライアントでの mix-up 攻撃対策。OAuth 2.1 で SHOULD 化。

5-4-11: コンテナ/自前 Node デプロイの補足

本記事は Cloudflare Workers を主軸にしましたが、Docker / Kubernetes 等で自前デプロイする場合の差分です。

# Dockerfile(Node 22 LTS、最小例)
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
# ★ PID 1 シグナル処理:node の前に tini を挟む(npm の child process が PID 1 にならないように)
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
// グレースフルシャットダウン(SIGTERM / SIGINT)
const server = app.listen(3000);
const shutdown = async (signal: string) => {
  console.log(`Received ${signal}, draining...`);
  // 1. 新規接続の受付停止
  server.close();
  // 2. 進行中の SSE ストリームを 30 秒以内にフラッシュ
  await Promise.race([
    drainAllSseStreams(),
    new Promise((r) => setTimeout(r, 30_000)),
  ]);
  // 3. 永続化ストアのフラッシュ
  await tokenStore.flush();
  process.exit(0);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

⚠️ agents/mcp 非使用時のセッション分離:Workers 環境では Durable Objects がセッション ID ごとに自動隔離してくれますが、自前 Node では インメモリ Map + sticky session(ロードバランサ)Redis 共有ストアで擬似的に再現します。複数インスタンスで横展開する場合は後者必須。

5-5: インシデント対応

リモートMCP特有のインシデントとして、トークン漏洩サーバー差し替え(Rug Pull) の2つを想定した対応フローを用意しておきます。

トークン漏洩時

  1. JWKS の signing key をローテーション(全トークン即時失効)
  2. 影響範囲調査(漏洩したトークンの利用ログ)
  3. 全ユーザーへの強制再認証
  4. 監査ログから不正利用の有無を確認
  5. 課金システムから不正利用分をロールバック(プロバイダの refund / credit API で)

Rug Pull 疑い時(攻撃者がサーバーへの書き込み権限を得た場合)

  1. デプロイパイプラインを停止
  2. 直近のデプロイ差分をレビュー
  3. ツール定義を直近の「監査済みバージョン」にロールバック
  4. 影響期間中の tools/call ログを精査
  5. ユーザーに通知(メール・ダッシュボード)

💡 インシデント対応 Runbook を docs/incident.md 等にコミットしておき、深夜の事故対応で迷わないようにします。Cloudflare Workers なら wrangler rollback で前のバージョンに即時戻せます。

📌 インシデント対応のタイムライン雛形

経過時間 アクション 担当
T+0 アラート受信・Incident channel 開設(Slack / Discord) オンコール
T+15min 一次切り分け(影響範囲・影響ユーザー数の特定) オンコール
T+30min トリアージ判定:(a) 全停止 (b) ロールバック (c) 部分機能停止 (d) 静観 オンコール → リード
T+1h ユーザー通知(ダッシュボード掲示 + メール/X 投稿) プロダクトオーナー
T+4h 一次封じ込め完了(恒久対策はこの時点では未完で OK) エンジニア
T+24h Post-Mortem 草稿・関係者レビュー リード
T+72h Post-Mortem 公開・恒久対策の Issue 化 リード

📌 エスカレーション先

  • Cloudflare 全体障害cloudflarestatus.com で状況確認、Enterprise なら TAM に直接連絡
  • Anthropic 側の Claude API / Desktop の問題status.anthropic.com
  • 依存 SaaS(Stripe / Auth0 等):各サービスの status ページ + Webhook 設定
  • CVE 報告:自社の脆弱性受付窓口(security@example.com)の応答 SLA を事前に決める

付録A: Local MCP からの移行チェックリスト

stdio で動いていたサーバーをそのままリモート化する前に、次のチェックを潰してください。

📌 コード変更チェックリスト

# 項目 確認
1 console.log の扱い stdio では禁止だったが HTTP では制限なし。ただしシークレットの混入だけは要 redact
2 process.env の使い方 stdio はユーザー固有シークレット置き場 / HTTP はサーバー全体共有設定。ユーザー固有値は DB へ移す
3 インメモリ状態 1プロセス1ユーザー前提のコードを「セッションストア経由」に書き換える
4 タイムアウト前提 stdio の60秒前提は HTTP でも有効。長時間処理は Progress / Tasks へ
5 絶対パス利用 サーバー側ファイルシステムは「サーバー所有」。ユーザーPC のファイルは扱えない
6 fs.realpath 等のパストラバーサル対策 stdio で書いたコードはそのまま生きる(撤去しない)

📌 OAuth / 認証チェックリスト

# 項目 確認
7 aud 検証 JWT の audience が自サーバーURLか確認
8 iss 検証 信頼する AS の発行者URLと一致するか
9 JWKS 署名検証 公開鍵の自動取得・キャッシュ・ローテーション対応
10 PKCE(S256) クライアント認可時に必須
11 resource パラメータ(RFC 8707) クライアントが認可・トークン要求に含めているか
12 Refresh Token Rotation 再利用検出時のチェーン失効が動くか
12-2 Token Revocation(RFC 7009) /revoke エンドポイントの公開・jti ブラックリスト
12-3 Token Introspection(RFC 7662) opaque token 採用時のみ。短命キャッシュ運用
12-4 Bearer in URL 禁止 クエリ文字列での受信を 400 で拒否
12-5 JTI Replay 対策 Authorization Code / Refresh Token / DPoP proof で必須

📌 セキュリティチェックリスト(後編 付録C と統合)

# 項目 確認
13 Origin / Host ヘッダ検証 DNS rebinding 対策
14 CORS ホワイトリスト方式・credentials 利用時は * 禁止
15 Mcp-Session-Id の不透明化 crypto.randomUUID()
16 TLS 1.2+ 必須 TLS 1.0/1.1 禁止・AEAD のみ・HSTS preload
16-2 HTTP セキュリティヘッダ HSTS / X-Content-Type-Options / Referrer-Policy / CSP
17 レート制限 多軸(user / client / IP / tool)・Workers は DO ベース
17-2 DDoS / Slow-Loris 対策 WAF・Content-Length 事前拒否・body サイズ上限
18 ログのシークレット redaction Pino の redact

📌 運用チェックリスト

# 項目 確認
19 /.well-known/oauth-protected-resource RFC 9728 準拠で公開
19-2 /.well-known/oauth-authorization-server RFC 9207 iss パラメータサポート明示
20 tools/list のバージョンメタ ビルドID・署名情報を _meta
21 モニタリング 呼び出し回数・レイテンシ・エラー率を計測
22 インシデント Runbook トークン漏洩・Rug Pull 対応手順
22-2 Disaster Recovery 演習 月1回のリストアテスト・鍵 rotation
23 公式 Registry 登録 DNS 検証 or GitHub OIDC で登録済み
24 DEPRECATED ポリシー 破壊的変更時の通知フロー
24-2 GDPR Art. 17 / 20 対応 削除権・データポータビリティの API
24-3 テスト戦略 ユニット・統合・ファズ・負荷・カオスの5層
24-4 法務・契約整備 DPA / SLA / サブプロセッサ開示

付録B: クライアント実装者向けチェックリスト

サーバー作者向けの記事ですが、Confused Deputy / Rug Pull / Token Replay 等の防御はクライアント実装者側の協力が必須です。サーバーの README / ドキュメントに「クライアント実装者へのお願い」として最低限のチェックリストを掲載しておくと、エコシステム全体の防御が固くなります。

📌 トークン保管場所の優先順位

クライアント形態 推奨保管先 避けるべき保管先
デスクトップアプリ(Electron / Tauri / Native) OS Keychain(macOS Keychain / Windows Credential Manager / libsecret) プレーンテキストファイル・localStorage
CLI ツール OS Keychain or AES-GCM 暗号化したファイル(鍵は OS Keychain) ~/.config/*.json の平文・環境変数の永続化
Web SPA / Web Extension HTTPOnly Cookie(CSRF 対策込み)or service worker memory localStorage / sessionStorage
Mobile(iOS / Android) iOS Keychain / Android Keystore SharedPreferences 平文・SQLite 平文

📌 mcp-remote ブリッジ経由のクライアントへの注意

  • トークン渡しmcp-remote は HOME 下に OAuth トークンキャッシュを置く(Linux/macOS は ~/.mcp-auth/、Windows は %USERPROFILE%\.mcp-auth\。環境変数 MCP_REMOTE_CONFIG_DIR で変更可。mcp-remote on npm 参照)。攻撃者がローカル権限を取った時の被害範囲を縮めるため、OS 標準のフルディスク暗号化を有効にする
  • CVE-2025-6514 影響範囲0.0.5〜0.1.15 を使うとローカルでコマンド実行されうる。README で 0.1.16 以降必須 をクライアント実装者に対して必ず周知

📌 クライアント側で必ず実装すべき項目

  1. PKCE(S256)の code_verifier を毎回新規生成(再利用禁止)
  2. resource パラメータ(RFC 8707)を必ず付与して MCP サーバー URL を明示
  3. state パラメータを crypto.randomUUID() 等で生成・受信時に検証
  4. Refresh Token は使い捨て(新トークン受領後、古いものを即時破棄)
  5. Last-Event-ID ヘッダで SSE 再接続(接続断時のメッセージ取りこぼし防止)
  6. MCP-Protocol-Version ヘッダを initialize 後の全リクエストに付与
  7. tools/list_changed 通知受信時に tools/list 再フェッチ
  8. サーバーから受信した _meta.signature を独立配信の公開鍵で検証(Rug Pull 検知)

付録C: テスト設計の実装サンプル集

📌 本付録の位置づけPart 5-4-2 はテスト戦略の全体表(5レイヤとツール選定の地図)です。本付録はそこで挙げた各レイヤの実装サンプルで、Part 1-6(テナント越境テスト)や Part 2-4(JWT 検証)の小サンプルと組み合わせて参照してください。本文と付録で扱う粒度が違う前提です。

OAuth フロー全体の E2E テスト(Playwright)

// test/e2e/oauth-flow.spec.ts
import { test, expect } from "@playwright/test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

test("full OAuth + MCP roundtrip", async ({ page }) => {
  // 1. 認可エンドポイントを開いてログイン
  await page.goto("https://auth.example.com/authorize?response_type=code&...");
  await page.fill("input[name=email]", "test@example.com");
  await page.fill("input[name=password]", "test-pw");
  await page.click("button:has-text('許可')");

  // 2. リダイレクトされた callback で code を受け取り、トークン交換
  await page.waitForURL(/\/oauth\/callback\?code=/);
  const accessToken = await exchangeCodeForToken(page.url(), {
    clientId: TEST_CLIENT_ID,
    codeVerifier: TEST_CODE_VERIFIER,   // PKCE: テスト開始時に生成して保持
    redirectUri: TEST_REDIRECT_URI,
  });

  // 3. MCP サーバーへ Bearer トークン付きで接続
  const transport = new StreamableHTTPClientTransport(
    new URL("https://mcp.example.com/mcp"),
    { requestInit: { headers: { Authorization: `Bearer ${accessToken}` } } }
  );
  const client = new Client({ name: "test", version: "0" }, {});
  await client.connect(transport);

  // 4. tools/list が返ってくることを確認
  const tools = await client.listTools();
  expect(tools.tools.length).toBeGreaterThan(0);
});

// ヘルパー:callback URL の code を AS の /token で access_token と交換する
async function exchangeCodeForToken(
  callbackUrl: string,
  opts: { clientId: string; codeVerifier: string; redirectUri: string },
): Promise<string> {
  const code = new URL(callbackUrl).searchParams.get("code");
  if (!code) throw new Error("missing code");
  const res = await fetch("https://auth.example.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      client_id: opts.clientId,
      code_verifier: opts.codeVerifier,
      redirect_uri: opts.redirectUri,
      resource: "https://mcp.example.com",   // RFC 8707
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
  const { access_token } = await res.json() as { access_token: string };
  return access_token;
}

Last-Event-ID による SSE resumption テスト

// collectSseEvents ヘルパー(テスト用スタブ)
// 本番テストでは undici / node-fetch の ReadableStream か EventSource を利用
async function collectSseEvents(
  token: string,
  opts: { maxEvents: number; headers?: Record<string, string> },
): Promise<{ id: string; data: string }[]> {
  const events: { id: string; data: string }[] = [];
  const res = await fetch("https://mcp.example.com/mcp", {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "text/event-stream",
      "Mcp-Session-Id": "test-session",
      ...opts.headers,
    },
  });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  while (events.length < opts.maxEvents) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value);
    const lines = buf.split("\n");
    buf = lines.pop() ?? "";
    let id = "", data = "";
    for (const line of lines) {
      if (line.startsWith("id:")) id = line.slice(3).trim();
      else if (line.startsWith("data:")) data = line.slice(5).trim();
      else if (line === "" && data) { events.push({ id, data }); id = ""; data = ""; }
    }
  }
  return events;
}

test("SSE resumption with Last-Event-ID", async () => {
  // 初回接続でイベントを 3 つ受信
  const events1 = await collectSseEvents(token, { maxEvents: 3 });
  const lastId = events1[events1.length - 1].id;

  // 切断後、Last-Event-ID 付きで再接続
  const events2 = await collectSseEvents(token, {
    headers: { "Last-Event-ID": lastId },
    maxEvents: 3,
  });

  // 取りこぼしがないこと(id が連続している)
  expect(parseInt(events2[0].id)).toBe(parseInt(lastId) + 1);
});

おわりに

リモートMCP化は、stdio で完成した1つのツールを、世界で使われるサービスに変える変換です。コードの大半は再利用できますが、信頼境界・スケール前提・脅威モデルが根本的に変わります。

本記事を一通り実装すれば、次の状態になります:

  • Streamable HTTP + OAuth 2.1 + PKCE で安全に認証
  • マルチテナント設計でNユーザーを1プロセスで捌ける
  • プラン別ツール露出課金フックでサブスクが成立
  • Cloudflare Workers で世界中に低レイテンシ配信
  • 公式 Registry 登録で発見可能性を最大化

ここから先は、ユーザーに「使われ続ける」MCPを作るフェーズです。具体的なツール設計の原則は後編(stdio 編)、概念とビジネスモデルは前編に戻って参照してください。

リモートMCPは早期発展期に入ったところです。2025年9月の公式 Registry プレビュー、2025-11-25 仕様での RFC 9728 整合 / CIMD 推奨化を経て、主要クライアント(Claude.ai / ChatGPT Developer Mode / Cursor)の対応も揃いました。一方で仕様・SDK は毎月更新が続き、ベストプラクティスも形成途上です。本記事が「最初の一歩」になり、皆さんが新しいパターンを発見していくきっかけになれば幸いです。

皆さんに聞いてみたいこと

  • リモートMCP化で一番苦労した点は何でしたか?(OAuth・スケール・課金…)
  • どのホスティングを選びましたか?(Cloudflare・Vercel・自前 VPS…)
  • マネタイズで効いた施策は?(サブスク・従量・エンタープライズ…)

コメント・引用 RT で意見交換できると嬉しいです。


参考文献

本シリーズ

MCP 仕様・公式情報

OAuth・関連 RFC

セキュリティ

Cloudflare / ホスティング

マネージドリモートMCP

運用・コンプライアンス

マネタイズ・運用

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?