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 | 認証なしの最小構成。ツールは | ||
2 | GitHub OAuth を追加。ステートレスハンドラのまま | ||
3 |
| ||
4 |
| stdio 版を追加して二層構成に |
稼働中のエンドポイント
認証なし(
phase1の構成):https://cloudflare-mcp-sample.ma2no4413.workers.dev/mcpGitHub OAuth(
phase2以降):https://cloudflare-mcp-sample-oauth.ma2no4413.workers.dev/mcp
別々の Worker として同時に動いている。main をそのままデプロイすると OAuth 版になる。
認証なしの構成を見たいときは phase1 タグを参照すること。
動作確認済みバージョン
パッケージ | バージョン |
Node.js | 22.23.2 |
| 0.20.1 |
| 2.0.0(完全固定) |
| 0.10.3( |
| 4.4.3 |
| 4.123.0 |
TypeScript | 5.9.3 |
Node.js 22 以上が必須。
create-cloudflareもwranglerもengines: { node: ">=22.0.0" }を宣言していて、 Node 20 では警告ではなくWrangler requires at least Node.js v22.0.0.で停止する。
@modelcontextprotocol/serverにキャレット (^2.0.0) を付けないこと。agents@0.20.1のpeerDependenciesは"@modelcontextprotocol/server": "2.0.0"と完全一致で固定されている。 公式ドキュメントも "Use the exact MCP versions required by your installed Agents release." と書いている。
セットアップ
npm installローカル実行
npm starthttp://127.0.0.1:8787/mcp で待ち受ける。
ドキュメントの手順には
8788と書かれているが、素の Worker をwrangler devで起動した場合は 8787 になる。 起動ログのReady on ...を必ず読むこと。
別ターミナルで MCP Inspector を起動して接続する。
npx @modelcontextprotocol/inspector@latestInspector に 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.dbMCP Inspector から確認する場合:
npx @modelcontextprotocol/inspector@latest --cli node dist/stdio.mjs local.db --method tools/listSQLite は Node 22 に同梱されている(
node:sqlite)。追加の依存は要らない。 実験的機能なので起動時にExperimentalWarningが出るが、stderr に出るので問題ない。stdout は MCP のプロトコル専用。
console.logを 1 回でも書くと JSON-RPC が壊れる。 ログを出すなら必ず stderr へ。
デプロイ
npx wrangler@latest deploymain(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で誰でも読める状態になる。
稼働中のエンドポイント:
phase1(認証なし): https://cloudflare-mcp-sample.ma2no4413.workers.dev/mcpphase2(OAuth): https://cloudflare-mcp-sample-oauth.ma2no4413.workers.dev/mcp
npx @modelcontextprotocol/inspector@latest --cli \
https://cloudflare-mcp-sample.ma2no4413.workers.dev/mcp \
--transport http --method tools/list初回は workers.dev サブドメインの登録が要る。 未登録のままでも
wrangler deployはSuccess!を返し、警告 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
引数 | 型 | 説明 |
|
|
|
列名・型・列ごとの欠損数・対象月の範囲を返す。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": "medium", "column": "sales", "month": null,
"sum": 10478099262.01, "counted": 9329, "skipped": 671, "rows": 10000 }counted + skipped = rows が常に成立する。同じ値が D1 版と stdio 版の両方で返る。
列名は allowlist で照合している。外れると isError: true と、集計できる列の一覧を返す。
sum_csv_column(phase1 のみ)
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 に、店舗別・月次売上を模した架空データを置いてある。
phase1 の sum_csv_column 用(D1 / SQLite 向けのデータは scripts/gen-dataset.mjs が作る)。
集計で現実に効いてくる「汚れ」を意図的に混ぜてある。
S004(休業)—salesもcustomersも空欄S006(POS 障害)—salesだけN/Aでcustomersは生きている
同じファイルでも、集計する列によってスキップ数が変わる。
列 |
|
|
|
|
| 6,054,200 | 6 | 2 | 8 |
| 2,161 | 7 | 1 | 8 |
sales の合計は 8 店舗中 6 店舗分でしかない。skipped を返さない設計だと、
これが「8 店舗の合計」として読まれる。ツールの戻り値に信頼度の材料を含める理由がこれ。
引用符付きフィールドの壊れ方
store_id,store_name,sales
S001,"Shibuya, Tokyo",1284500
S002,"Shinjuku, Tokyo",1650000sum: 0, counted: 0, skipped: 2 が返る。引用符内のカンマで列がずれ、
sales の位置に Tokyo" が来て全行スキップされる。
間違った合計を返すのではなく skipped が全行に立つので、
戻り値だけで壊れていると分かる。素朴なパーサとしては悪くない壊れ方といえる。
なぜ McpAgent ではなく createMcpHandler なのか
McpAgent は deprecated かつ機能凍結されている。公式ドキュメントの
Handler API が
明示的に "McpAgent is deprecated and feature-frozen" と書いており、新規サーバは
createMcpHandler を使うことになっている。createLegacyMcpHandler はレガシー移行専用。
構造的な理由は状態の置き場所にある。
McpAgentは Durable Object にセッション状態を持たせる設計だった。接続ごとに DO インスタンスが立つ。createMcpHandlerはステートレス。リクエストごとにファクトリ関数からMcpServerを作って捨てる。 状態が要るなら D1 / KV / R2 / Durable Objects に明示的に置く。
状態を持たないサーバに DO を強制されないぶん、コールドスタートも課金も素直になる。 今回のような「引数を受け取って計算して返すだけ」のツールに、セッションは要らない。
agents/mcp/server の createMcpHandler は、実体は createStatelessMcpHandler の別名で、
MCP SDK 側の createMcpHandler を Workers 向けにラップしたもの。次のオプションを足している。
オプション | 既定値 | 内容 |
|
| この Worker が処理する pathname(完全一致) |
| — | CORS ヘッダ。 |
| localhost と | 受け付ける |
| 同上 | 受け付けるブラウザ |
| — |
|
これに加えて、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 箇所にある
インポート元 | 正体 |
| これが正解。 |
| 互換用のオーバーロード。ファクトリを渡せば同じ挙動だが、SDK v1 のサーバを渡すと deprecated 経路に落ちる |
| 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 つ。
apiHandlerは「fetchを持つオブジェクト」を正式に受け付ける。 型はExportedHandlerWithFetch<Env> | (new (ctx, env) => WorkerEntrypointWithFetch<Env>)。McpAgent.serve()はその前者を返しているだけで、特別な仕組みではない。authContextを渡す必要はない。 ハンドラがctx.propsを自動で解決する (handler-stateless.jsのresolvedAuthContext)。渡した場合は上書きになる。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 を送ってくるようになってからでよいと考えている。