Cloudflare Sample
БесплатноНе проверенA stateless MCP server on Cloudflare Workers providing a ping tool and a CSV column summarizer, demonstrating the createMcpHandler API.
Описание
A stateless MCP server on Cloudflare Workers providing a ping tool and a CSV column summarizer, demonstrating the createMcpHandler API.
README
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 | 認証なしの最小構成。ツールは ping と sum_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 OAuth(
phase2以降):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-cloudflareもwranglerもengines: { node: ">=22.0.0" }を宣言していて、 Node 20 では警告ではなくWrangler requires at least Node.js v22.0.0.で停止する。
@modelcontextprotocol/serverにキャレット (^2.0.0) を付けないこと。[email protected]のpeerDependenciesは"@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で誰でも読める状態になる。
稼働中のエンドポイント:
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
| 引数 | 型 | 説明 |
|---|---|---|
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_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は生きている
同じファイルでも、集計する列によってスキップ数が変わる。
| 列 | 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 なのか
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 向けにラップしたもの。次のオプションを足している。
| オプション | 既定値 | 内容 |
|---|---|---|
route |
"/mcp" |
この Worker が処理する pathname(完全一致) |
corsOptions |
— | CORS ヘッダ。false で無効化 |
allowedHostnames |
localhost と workers.dev |
受け付ける Host |
allowedOriginHostnames |
同上 | 受け付けるブラウザ Origin。Origin なしの非ブラウザクライアントは常に通る |
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 つ。
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 を送ってくるようになってからでよいと考えている。
Установка Cloudflare Sample
У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.
▸ github.com/ma2no4413/cloudflare-mcp-sampleFAQ
Cloudflare Sample MCP бесплатный?
Да, Cloudflare Sample MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Cloudflare Sample?
Нет, Cloudflare Sample работает без API-ключей и переменных окружения.
Cloudflare Sample — hosted или self-hosted?
Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.
Как установить Cloudflare Sample в Claude Desktop, Claude Code или Cursor?
Открой Cloudflare Sample на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
GitHub
PRs, issues, code search, CI status
автор: GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
автор: mcpdotdirectAmap Maps Mcp Server
MCP server for using the AMap Maps API
автор: duxiaohuiSupabase
Database, auth and storage
автор: SupabaseEverything
Reference / test server with prompts, resources, and tools.
Git
Tools to read, search, and manipulate Git repositories.
Sequential Thinking
Dynamic and reflective problem-solving through thought sequences.
Time
Time and timezone conversion capabilities.
Compare Cloudflare Sample with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории development
