Skip to main content
Glama
ma2no4413

cloudflare-mcp-sample

cloudflare-mcp-sample

Cloudflare Workers 上にステートレス方式の MCP サーバを建てるサンプル。

agents パッケージの createMcpHandler を直に使い、テンプレート任せにしない。 非推奨の McpAgent は一切使っていない。

認証なしの最小構成から始めて、OAuth、D1、stdio 版と 4 つの Phase に分けて作った。 Phase ごとにタグを切ってあり、それぞれに解説記事がある。

なぜ Phase に分けたか

理由は 3 つある。

1. 記事が指す先を固定するため。 記事は「動くコード」を指す必要がある。main を指すと、コードが進むたびに 過去の記事の説明とズレていく。タグを指せば、1 年後に読んでも記事のとおりに動く

2. 未知を 1 つずつ潰すため。 Phase 2(OAuth をステートレスハンドラに繋ぐ)は、着手時点で公式に動く実例が存在しなかった。 ここが通らなければ Phase 3 以降の実装は無駄になる。先に一番不確かなものを試して、 通らなければ引き返せるようにしてある。実際、各 Phase の着手前に撤退条件を決めていた。

3. 前の Phase を壊さないため。 phase1 の認証なし構成は、いまも別の Worker として動いている。 記事に「この URL を叩くとこう返る」と書いた以上、あとから壊すと記事が嘘になる。 だから Phase 2 では上書きせず、別名でデプロイした。

各 Phase の中身は「その時点で最小限のもの」に絞ってある。ついでの機能追加をしないという 制約でもあり、これが無いとスコープが際限なく膨らむ。

Phase と対応する記事

タグごとにコードが完結している。記事はタグを指しているので、あとから読んでも説明とコードがずれない。

Phase

タグ

何をしたか

記事

1

phase1

認証なしの最小構成。ツールは pingsum_csv_column

McpAgent は凍結された / 踏んだ 5 つの落とし穴 / CSV を引数で渡す設計をやめた

2

phase2

GitHub OAuth を追加。ステートレスハンドラのまま

OAuth を載せる

3

phase3

dataset_id で D1 を指す。ツールを 3 本に

CPU 制限を実測 / ツールを 1 本から 3 本に

4

phase4 = main

stdio 版を追加して二層構成に

二層構成(最終回)

稼働中のエンドポイント

  • 認証なしphase1 の構成): https://cloudflare-mcp-sample.ma2no4413.workers.dev/mcp

  • GitHub OAuthphase2 以降): https://cloudflare-mcp-sample-oauth.ma2no4413.workers.dev/mcp

別々の Worker として同時に動いている。main をそのままデプロイすると OAuth 版になる。 認証なしの構成を見たいときは phase1 タグを参照すること。

動作確認済みバージョン

パッケージ

バージョン

Node.js

22.23.2

agents

0.20.1

@modelcontextprotocol/server

2.0.0(完全固定

@cloudflare/workers-oauth-provider

0.10.3(phase2 以降)

zod

4.4.3

wrangler

4.123.0

TypeScript

5.9.3

Node.js 22 以上が必須。 create-cloudflarewranglerengines: { node: ">=22.0.0" } を宣言していて、 Node 20 では警告ではなく Wrangler requires at least Node.js v22.0.0.停止する

@modelcontextprotocol/server にキャレット (^2.0.0) を付けないこと。 agents@0.20.1peerDependencies"@modelcontextprotocol/server": "2.0.0"完全一致で固定されている。 公式ドキュメントも "Use the exact MCP versions required by your installed Agents release." と書いている。

セットアップ

npm install

ローカル実行

npm start

http://127.0.0.1:8787/mcp で待ち受ける。

ドキュメントの手順には 8788 と書かれているが、素の Worker を wrangler dev で起動した場合は 8787 になる。 起動ログの Ready on ... を必ず読むこと。

別ターミナルで MCP Inspector を起動して接続する。

npx @modelcontextprotocol/inspector@latest

Inspector に http://127.0.0.1:8787/mcp を入力 → Connect → List Tools。

CLI モードでも確認できる。

npx @modelcontextprotocol/inspector@latest --cli http://127.0.0.1:8787/mcp --transport http --method tools/list

/mcp をブラウザで直接開いても確認にはならない。MCP クライアントが話す JSON-RPC を、ブラウザは話さない。

stdio 版(ローカル)

同じツールを、ローカルの SQLite に対して動かせる。HTTP 版とツール定義を共有しているので、 違うのはデータの置き場所だけ。認証は無い(ローカルプロセスに OAuth を挟む相手がいない)ため、 whoami も無い。

データを用意する

node scripts/gen-dataset.mjs --rows 1000  --id small  --out schema/seed-small.sql
node scripts/gen-dataset.mjs --rows 10000 --id medium --out schema/seed-medium.sql

node -e "
const {DatabaseSync}=require('node:sqlite');const fs=require('fs');
const db=new DatabaseSync('local.db');
db.exec(fs.readFileSync('schema/schema.sql','utf8'));
for (const f of ['small','medium']) db.exec(fs.readFileSync('schema/seed-'+f+'.sql','utf8'));
"

D1 と同じスキーマ・同じ生成スクリプトを使う。だから両方で同じ数字が返る。

起動する

npm run build:stdio
node dist/stdio.mjs local.db

MCP Inspector から確認する場合:

npx @modelcontextprotocol/inspector@latest --cli node dist/stdio.mjs local.db --method tools/list

SQLite は Node 22 に同梱されているnode:sqlite)。追加の依存は要らない。 実験的機能なので起動時に ExperimentalWarning が出るが、stderr に出るので問題ない

stdout は MCP のプロトコル専用。console.log を 1 回でも書くと JSON-RPC が壊れる。 ログを出すなら必ず stderr へ。

デプロイ

npx wrangler@latest deploy

main(OAuth 版)をデプロイする場合は、先に以下が要る。

npx wrangler kv namespace create "OAUTH_KV"   # 出力された id を wrangler.jsonc に書く
npx wrangler secret put GITHUB_CLIENT_ID      # 値はプロンプトに入力する
npx wrangler secret put GITHUB_CLIENT_SECRET
npx wrangler secret put COOKIE_ENCRYPTION_KEY # openssl rand -hex 32 など

wrangler secret put <値> と書かないこと。引数は「名前」で、値はプロンプトに入力する。 逆にすると値がシークレット名として登録され、wrangler secret list で誰でも読める状態になる。

稼働中のエンドポイント:

npx @modelcontextprotocol/inspector@latest --cli \
  https://cloudflare-mcp-sample.ma2no4413.workers.dev/mcp \
  --transport http --method tools/list

初回は workers.dev サブドメインの登録が要る。 未登録のままでも wrangler deploySuccess! を返し、警告 1 行を出すだけで成功したように見える。 しかし実際にアクセスすると DNS はワイルドカードで引けるのに TLS ハンドシェイクで落ちる (Windows では SEC_E_ILLEGAL_MESSAGE)。ダッシュボードの Workers & Pages → Subdomain で登録し、 もう一度 wrangler deploy を実行すると URL が正しくなる。 証明書が行き渡るまで実測で約 75 秒かかった。

サブドメインはアカウントに 1 つしか持てず、配下の全 Worker の URL に入る。 Worker 名と同じ文字列を選ぶと foo.foo.workers.dev になるので、ハンドル名にしておくのが無難。

Claude Code から使う

.mcp.json を同梱してあるので、clone してディレクトリを開けばそのまま接続できる。

{
  "mcpServers": {
    "cloudflare-mcp-sample": {
      "type": "http",
      "url": "https://cloudflare-mcp-sample.ma2no4413.workers.dev/mcp"
    }
  }
}

初回のみ信頼の承認プロンプトが出る(project スコープの MCP サーバは無条件には読み込まれない)。 自分で建てたサーバに向ける場合は URL を書き換えるか、claude mcp add で入れ直す。

claude mcp add --transport http --scope project <name> https://<worker>.<subdomain>.workers.dev/mcp

.mcp.json には認証なし版と OAuth 版の両方を入れてある。OAuth 版は初回接続時に ブラウザが開き、GitHub のログインを求められる。

認証なし版は URL を知っていれば誰でも叩ける。機微データを流さないこと。

ツール

ping

引数なし。サーバ名・バージョンとサーバ側の現在時刻 (UTC) を JSON で返す。

{
  "server": "cloudflare-mcp-sample",
  "version": "0.1.0",
  "now": "2026-08-16T10:06:55.511Z"
}

list_datasets

引数なし。集計できるデータセットの一覧と行数を返す。

[{ "id": "small", "label": "店舗別・月次売上(合成データ / 1000 行)", "rows": 1000 }]

describe_dataset

引数

説明

dataset_id

string

list_datasets が返した ID

列名・型・列ごとの欠損数・対象月の範囲を返す。aggregate を呼ぶ前に見るためのもの。

{
  "id": "large", "rows": 100000,
  "month_range": { "from": "2024-01", "to": "2026-12" },
  "columns": [
    { "name": "sales", "type": "REAL", "aggregatable": true, "missing": 6698 },
    { "name": "customers", "type": "INTEGER", "aggregatable": true, "missing": 4107 }
  ]
}

aggregate

引数

説明

dataset_id

string

list_datasets が返した ID

column

string

集計する列名

month

string(任意)

YYYY-MM。指定するとその月だけ

データ本体は受け取らない。指定列を合計し、数値化できない行の件数も返す。

{ "dataset_id": "medium", "column": "sales", "month": null,
  "sum": 10478099262.01, "counted": 9329, "skipped": 671, "rows": 10000 }

counted + skipped = rows が常に成立する。同じ値が D1 版と stdio 版の両方で返る。

列名は allowlist で照合している。外れると isError: true と、集計できる列の一覧を返す。

sum_csv_columnphase1 のみ)

main には存在しない。CSV 本体を引数で渡す設計は、データがまるごとモデルの コンテキストを通るため破棄した。認証なし版の Worker と phase1 タグには残っている。

whoami

HTTP 版のみphase2 以降)。stdio 版には無い。引数なし。認証済みユーザの GitHub アカウント情報を返す。

{ "login": "ma2no4413", "name": "ma2no4413", "githubId": 130893267 }

getMcpAuthContext() が返す props を読んでいる。props は OAuthProvider が アクセストークンに封入したもの。GitHub のアクセストークンは意図的に保持していない (ツールから GitHub API を叩かないため)。

サンプルデータ

examples/store-sales.csv に、店舗別・月次売上を模した架空データを置いてある。 phase1sum_csv_column 用(D1 / SQLite 向けのデータは scripts/gen-dataset.mjs が作る)。 集計で現実に効いてくる「汚れ」を意図的に混ぜてある。

  • S004(休業)— salescustomers も空欄

  • S006(POS 障害)— sales だけ N/Acustomers は生きている

同じファイルでも、集計する列によってスキップ数が変わる。

sum

counted

skipped

rows

sales

6,054,200

6

2

8

customers

2,161

7

1

8

sales の合計は 8 店舗中 6 店舗分でしかない。skipped を返さない設計だと、 これが「8 店舗の合計」として読まれる。ツールの戻り値に信頼度の材料を含める理由がこれ。

引用符付きフィールドの壊れ方

store_id,store_name,sales
S001,"Shibuya, Tokyo",1284500
S002,"Shinjuku, Tokyo",1650000

sum: 0, counted: 0, skipped: 2 が返る。引用符内のカンマで列がずれ、 sales の位置に Tokyo" が来て全行スキップされる。 間違った合計を返すのではなく skipped が全行に立つので、 戻り値だけで壊れていると分かる。素朴なパーサとしては悪くない壊れ方といえる。

なぜ McpAgent ではなく createMcpHandler なのか

McpAgentdeprecated かつ機能凍結されている。公式ドキュメントの Handler API が 明示的に "McpAgent is deprecated and feature-frozen" と書いており、新規サーバは createMcpHandler を使うことになっている。createLegacyMcpHandler はレガシー移行専用。

構造的な理由は状態の置き場所にある。

  • McpAgent は Durable Object にセッション状態を持たせる設計だった。接続ごとに DO インスタンスが立つ。

  • createMcpHandlerステートレス。リクエストごとにファクトリ関数から McpServer を作って捨てる。 状態が要るなら D1 / KV / R2 / Durable Objects に明示的に置く。

状態を持たないサーバに DO を強制されないぶん、コールドスタートも課金も素直になる。 今回のような「引数を受け取って計算して返すだけ」のツールに、セッションは要らない。

agents/mcp/servercreateMcpHandler は、実体は createStatelessMcpHandler の別名で、 MCP SDK 側の createMcpHandler を Workers 向けにラップしたもの。次のオプションを足している。

オプション

既定値

内容

route

"/mcp"

この Worker が処理する pathname(完全一致)

corsOptions

CORS ヘッダ。false で無効化

allowedHostnames

localhost と workers.dev

受け付ける Host

allowedOriginHostnames

同上

受け付けるブラウザ OriginOrigin なしの非ブラウザクライアントは常に通る

authContext

getMcpAuthContext() から読める props

これに加えて、SDK 側の legacy / onerror / responseMode / maxSubscriptions / keepAliveMs が渡せる。

実装上の落とし穴

1. ハンドラを default export に直接置かない

// NG: Wrangler が「関数の default export」を WorkerEntrypoint クラスとして解釈する
export default createMcpHandler(createServer);

// OK
export default {
  fetch(request, env, ctx) {
    return createMcpHandler(createServer)(request, env, ctx);
  },
} satisfies ExportedHandler<Env>;

NG 版で起きることを実際に確かめた。wrangler deploy --dry-run は通る。 バンドルは成功し Total Upload: 991.85 KiB まで表示される。落ちるのはランタイムの起動時

X [ERROR] service core:user:cloudflare-mcp-sample:
  Uncaught TypeError: Class extends value (request, _env, ctx) => serve(request, void 0, ctx)
  is not a constructor or null
      at wrapWorkerEntrypoint

X [ERROR] The Workers runtime failed to start.

Wrangler は関数の default export を WorkerEntrypoint として extends しようとする。 関数はコンストラクタではないのでここで死ぬ。ビルドが通ったことは何の保証にもならない。

2. サーバインスタンスではなくファクトリを渡す

グローバルに 1 つ McpServer を作って使い回さない。createMcpHandler に渡すのは関数そのもので、 ハンドラがリクエストごとにサーバを生成する。

3. 引数なしツールでは inputSchema を「省略」する

inputSchema: {} を渡すと registerTool のオーバーロード解決が壊れ、 戻り値の type: "text"string に広がって型エラーになる。空オブジェクトではなくキーごと消す

4. createMcpHandler という名前は 3 箇所にある

インポート元

正体

agents/mcp/server

これが正解。createStatelessMcpHandler の別名

agents/mcp

互換用のオーバーロード。ファクトリを渡せば同じ挙動だが、SDK v1 のサーバを渡すと deprecated 経路に落ちる

@modelcontextprotocol/server

MCP SDK 自身のもの。Workers 向けのラッパではない

厄介なのは 2 番目で、型が通りファクトリを渡している限り動くので気付けない。 import { createMcpHandler } from "agents/mcp/server" と、/server まで書かれているかを確認すること。

所感 — CSV を引数で渡す方式の限界(Phase 1 時点)

sum_csv_column は本命(店舗別 CSV の横断集計)の最小プロトタイプとして書いた。 実際に動かして分かった限界を残しておく。

ここに書いた 1 つ目の限界は Phase 3 で解消した。dataset_id で D1 上のデータを 指す形に移してある(経緯)。

  • CSV 本体を引数で渡す設計は、そのままではスケールしない。 文字列がまるごとモデルのコンテキストを通るため、 数百 KB の実データを渡した時点でトークンを食い潰す。集計は「データを送る」のではなく 「データの置き場所を指す ID を送る」形にすべきで、本命は R2/D1 に置いたデータを dataset_id で指す設計になる。

  • skipped を返す設計は正解だった。 数値化できない行を黙って捨てると、モデルは合計値を無条件に信じる。 スキップ件数が返っていれば「5 行中 2 行落ちています」とモデル自身が但し書きを付けられる。 ツールの戻り値は「答え」だけでなく「答えの信頼度を判断する材料」を含めるべき。

  • エラーは例外ではなく isError: true で返す。 列名が無いときに実際のヘッダ一覧を添えて返すと、 モデルはその場で列名を直して再実行できる。エラーメッセージがそのままリトライの入力になる。

  • MCP Inspector の CLI は --tool-arg に改行を含む値を渡せない。 改行で引数が分割され、 ヘッダ行だけが届いて rows: 0 が返る。複数行の値を試すときは Inspector の UI を使うか、 fetch で JSON-RPC を直接叩くこと(CLI のパーサの制約であって、サーバ側の問題ではない)。

Phase ごとに決めたこと

Phase 1 は「往復が通ること」と「正しい API を使っていること」だけを成果物にした。 そこから 1 つずつ足していった。各 Phase で何を決めたかを残しておく。

Phase 1 の内容は上の「なぜ McpAgent ではなく createMcpHandler なのか」と 「実装上の落とし穴」にある。

Phase 2 — 認証(OAuth 2.1)— 実装済み

@cloudflare/workers-oauth-provider が OAuth 2.1 のプロバイダ側を肩代わりし、 GitHub を上流 IdP として使う。認証済みユーザの情報はツール内から getMcpAuthContext() で読む。

この組み合わせの動く実例は、着手時点で公式に存在しなかった。 authless のデモは createMcpHandler に移行済みだが、OAuth のデモ (cloudflare/ai/demos/remote-mcp-github-oauth) は McpAgent + Durable Objects のままで this.props を使っている。一方ドキュメントは getMcpAuthContext() を使えと書いている。

型定義と実装を読んで確定させた結論は 3 つ。

  1. apiHandler は「fetch を持つオブジェクト」を正式に受け付ける。 型は ExportedHandlerWithFetch<Env> | (new (ctx, env) => WorkerEntrypointWithFetch<Env>)McpAgent.serve() はその前者を返しているだけで、特別な仕組みではない。

  2. authContext を渡す必要はない。 ハンドラが ctx.props を自動で解決する (handler-stateless.jsresolvedAuthContext)。渡した場合は上書きになる。

  3. durable_objects / migrations / nodejs_compat はいずれも不要。 公式デモには 3 つとも入っているが、McpAgent と octokit/hono のためと思われる。

const apiHandler = {
  fetch(request, env, ctx) {
    return createMcpHandler(createServer)(request, env, ctx);
  },
};

export default new OAuthProvider({
  apiRoute: "/mcp",
  apiHandler,
  defaultHandler: GitHubHandler,
  authorizeEndpoint: "/authorize",
  tokenEndpoint: "/token",
  clientRegistrationEndpoint: "/register",
});

src/github-handler.ts は公式デモを写していない。認可要求は Cookie ではなく GitHub の state に載せて往復させ、HMAC-SHA256 で署名して改竄を防いでいる (そのぶん承認ダイアログの実装が要らない)。GitHub のアクセストークンは props に保持しない。

この件は cloudflare/agents#2124 に報告し、 検証結果を返信してある。

Phase 3 — データの置き場所(D1)— 実装済み

sum_csv_column で分かったとおり、CSV 本体を引数で渡す設計はスケールしない。 文字列がまるごとモデルのコンテキストを通るため、実データ規模でトークンが破綻する。

「データを送る」のではなく「データの置き場所を指す ID を送る」形に変えた。

  • 引数は dataset_id とクエリ条件だけ。実体は D1 に置く

  • 集計はアプリ側でループを回さず D1 の SQL に寄せる

  • describe_dataset で列名・型・列ごとの欠損数を返す。モデルに列名を推測させない

  • 空欄も N/A も NULL に倒すと、COUNT(col)COUNT(*) の差がそのまま skipped になる

SQL に寄せる理由は「CPU 制限に当たるから」ではなかった。 着手時はそう考えていたが、実測すると Free プランでも 100 万行の Worker ループ集計が通る。 正しい理由はスケールが平坦だからで、行数を 1000 倍にしても SQL 版は 0.62 → 0.69 秒、 ループ版は 0.67 → 2.65 秒だった。詳細は実測の記事に書いた。

Phase 4 — 二層構成 — 実装済み

同じツールを、HTTP(Workers + D1)と stdio(ローカル + SQLite)の両方で動かす。

ツール定義は src/tools.ts の 1 箇所にある。データソースは最小のインターフェース (prepare / bind / all / first の 4 つだけ)で受け取るので、 ツール側に D1 も SQLite も出てこない。汎用の DB 抽象レイヤは作っていない。

src/tools.ts      ツール定義(共有)
src/index.ts      HTTP 版。D1 を渡す
src/stdio.ts      stdio 版。node:sqlite を渡す

SQLite は Node 22 同梱の node:sqlite を使うので、実行時の追加依存はゼロ。

whoami は HTTP 版にしかない。ローカルプロセスに OAuth を挟む相手がいないので、 stdio 版には返すものが無い。

npm 公開と公開レジストリへの掲載はやっていない。合成データのデモであって、 他人が入れる価値が無く、レジストリのノイズになるため。掲載手順そのものは別プロジェクトで通してある。

テスト — 入れた(Phase 4)

Phase 1 では入れていなかった。ツールが 2 本で、検証が Inspector の往復で足りているうちは、 テストを置いても実際には守られないと判断したため。

Phase 3 で集計ロジックが SQL に移った時点で条件を満たしたので、Phase 4 で入れた。

npm test
# tests 7
# pass 7

守っているのは「壊れても気付けない」性質のものだけ。網羅率は追っていない。

  • counted + skipped = rows が常に成立すること

  • 同じデータでも列によって欠損数が変わること

  • month で絞れること

  • 集計できない列・存在しない dataset_id で、使える値を添えて失敗すること

  • describe_dataset が列ごとの欠損数を返すこと

インメモリの SQLite に対して実行するので、D1 も Workers も要らない。 Db インターフェースを切った副産物で、実行環境を用意せずにテストできるようになった。

テストランナーは Node 22 同梱の node:test、TypeScript は --experimental-strip-types でそのまま実行している。追加依存はゼロ。

CI はまだ無い。手元で npm test が走ればよい規模なので、 GitHub Actions を足すのは、他人が PR を送ってくるようになってからでよいと考えている。