Skip to main content
Glama
ma2no4413

cloudflare-mcp-sample

README.md
# 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`](https://github.com/ma2no4413/cloudflare-mcp-sample/tree/phase1) | 認証なしの最小構成。ツールは `ping` と `sum_csv_column` | [McpAgent は凍結された](https://zenn.dev/ma2no4413/articles/298ef3273c2528) / [踏んだ 5 つの落とし穴](https://zenn.dev/ma2no4413/articles/cloudflare-mcp-five-pitfalls) / [CSV を引数で渡す設計をやめた](https://zenn.dev/ma2no4413/articles/mcp-tool-design-csv-argument) |
| 2 | [`phase2`](https://github.com/ma2no4413/cloudflare-mcp-sample/tree/phase2) | GitHub OAuth を追加。ステートレスハンドラのまま | [OAuth を載せる](https://zenn.dev/ma2no4413/articles/cloudflare-mcp-oauth-stateless) |
| 3 | [`phase3`](https://github.com/ma2no4413/cloudflare-mcp-sample/tree/phase3) | `dataset_id` で D1 を指す。ツールを 3 本に | [CPU 制限を実測](https://zenn.dev/ma2no4413/articles/cloudflare-workers-cpu-limit-measured) / [ツールを 1 本から 3 本に](https://zenn.dev/ma2no4413/articles/mcp-dataset-id-tool-design) |
| 4 | [`phase4`](https://github.com/ma2no4413/cloudflare-mcp-sample/tree/phase4) = `main` | stdio 版を追加して二層構成に | [二層構成(最終回)](https://zenn.dev/ma2no4413/articles/mcp-stdio-and-http-two-layers) |

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

- **認証なし**(`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`) を付けないこと。
> `agents@0.20.1` の `peerDependencies` は `"@modelcontextprotocol/server": "2.0.0"` と**完全一致で固定**されている。
> 公式ドキュメントも "Use the exact MCP versions required by your installed Agents release." と書いている。

## セットアップ

```bash
npm install
```

## ローカル実行

```bash
npm start
```

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

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

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

```bash
npx @modelcontextprotocol/inspector@latest
```

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

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

```bash
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` も無い。

### データを用意する

```bash
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 と同じスキーマ・同じ生成スクリプト**を使う。だから両方で同じ数字が返る。

### 起動する

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

MCP Inspector から確認する場合:

```bash
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 へ。

## デプロイ

```bash
npx wrangler@latest deploy
```

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

```bash
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/mcp**
- `phase2`(OAuth): **https://cloudflare-mcp-sample-oauth.ma2no4413.workers.dev/mcp**

```bash
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 してディレクトリを開けばそのまま接続できる。

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

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

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

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

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

## ツール

### `ping`

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

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

### `list_datasets`

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

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

### `describe_dataset`

| 引数 | 型 | 説明 |
| --- | --- | --- |
| `dataset_id` | `string` | `list_datasets` が返した ID |

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

```json
{
  "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`。指定するとその月だけ |

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

```json
{ "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 アカウント情報を返す。

```json
{ "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 店舗の合計」として読まれる**。ツールの戻り値に信頼度の材料を含める理由がこれ。

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

```csv
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](https://developers.cloudflare.com/agents/model-context-protocol/apis/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 に直接置かない

```ts
// 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 上のデータを
指す形に移してある([経緯](https://zenn.dev/ma2no4413/articles/mcp-dataset-id-tool-design))。

- **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.js` の `resolvedAuthContext`)。渡した場合は上書きになる。
3. **`durable_objects` / `migrations` / `nodejs_compat` はいずれも不要。**
   公式デモには 3 つとも入っているが、`McpAgent` と octokit/hono のためと思われる。

```ts
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](https://github.com/cloudflare/agents/issues/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 秒だった。詳細は[実測の記事](https://zenn.dev/ma2no4413/articles/cloudflare-workers-cpu-limit-measured)に書いた。

### 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 で入れた。**

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