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

Related MCP server: Hello MCP Server

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 を送ってくるようになってからでよいと考えている。

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ma2no4413/cloudflare-mcp-sample'

If you have feedback or need assistance with the MCP directory API, please join our Discord server