Skip to main content
Glama
supermanko1102

mol-open-data-mcp

README.md
# mol-open-data-mcp

勞動部開放資料 MCP Server — 部署在 Cloudflare Workers 的 remote MCP,
讓任何 MCP client(Claude、ChatGPT、Cursor…)可以查詢:

- **違反勞動法令事業單位名單**(勞基法 109896、就服法 110908)— 支援公司名稱**模糊搜尋**
- 勞動部所有開放資料集的搜尋、詮釋資料、以及 datastore 內容查詢

上游 API:勞動部 OAS 標準 Open API(`https://apiservice.mol.gov.tw/OdService`)。

## 架構

```
MCP client ── /mcp ──> Workers (McpAgent, Durable Object)
                          │
                          ├─ 即時 proxy ──> 勞動部 OdService API
                          │                 (find_datasets / get_dataset_info / query_data)
                          │
                          └─ D1 (SQLite) <── 每日 cron 增量同步
                                            + FTS5 trigram 索引(search_violations 模糊比對用)
```

**為什麼要 D1 快取**:勞動部 datastore API 的 `filters` 只支援**精確比對**,
「查鼎泰豐」對不到「鼎泰豐小吃店股份有限公司」。所以把名單同步進 D1,
存一份正規化名稱(去空白/去公司後綴/全形轉半形),查詢時用同一套正規化比對。

**為什麼是增量而不是每天全量重建**:勞基法名單實測有 **76,919 筆**。
每天砍掉重建等於一天 15 萬次以上的 rows written,直接爆掉 Workers 免費版的
100,000 rows written/day;77 頁分頁也會撞上「每次調用 50 次 subrequest」的上限。
實際上這份名單是累積型的,每天只新增約 26 筆,所以改成:

- **初次灌檔**走離線 seed(`scripts/seed.mjs`),跑在你自己的機器上,不受 Worker 配額限制
- **之後每日 cron** 只從上次掃到的位置往後掃,`INSERT OR IGNORE` 去重

## 上游 API 的坑(實測結論,改程式前請先看這段)

這些行為與官方 OAS 文件不符,是 2026-07 實際打 API 量出來的:

| 現象 | 實測 | 影響 |
|---|---|---|
| `result.total` | **永遠不回傳** | 不能用 total 判斷分頁結束,只能掃到空頁為止 |
| `result.fields` | 恆為空陣列 | 拿不到欄位 schema |
| `sort` 降冪 | `欄位 desc`、`-欄位`、`欄位:desc` **全部被拒** | 只能升冪,所以「拿最新資料」= 從尾端 offset 往後掃 |
| 不帶 `sort` 分頁 | 順序**不穩定**,第 1 頁與第 2 頁**重疊 390/1000 筆** | 分頁一律要帶 `sort`,否則會同時漏抓又重複抓 |
| 帶 `sort` 分頁 | 仍有少量重疊(同日期 tie-break 不穩,約 13/1000) | 還是要靠 `dedup_key` 去重 |
| 一個資料集多組資源 | 109896 有 **8 個** distribution(2 組 × 4 格式),兩個 JSON 的 `resourceModified` **差兩個月** | 必須取最新的那個,不能取第一個 |
| `resourceAmount` | 字串 `"0"`,不可信 | 不能拿來當筆數 |

資料量(2026-07 實測):勞基法 76,919 筆、就服法 84 筆。
勞基法的罰鍰金額只有 45,912 筆(60%)有值。

## 首次部署

```bash
npm install
npx wrangler login

# 1. 建 D1 資料庫,把回傳的 database_id 填入 wrangler.jsonc
npx wrangler d1 create mol_mcp_db

# 2. 建表(含 FTS5 索引與觸發器)
npm run db:init

# 3. 產生初始資料並灌進去(在本機跑,不受 Worker 配額限制)
npm run seed:build      # 抓上游 → 產生 seed.sql,約 1 分鐘
npm run seed:remote     # wrangler d1 execute --remote --file=./seed.sql

# 4. 設定 /admin/sync 的驗證 token(沒設的話該端點直接回 404)
npx wrangler secret put ADMIN_TOKEN

# 5. 部署
npx wrangler deploy
```

之後每天 UTC 19:30(台灣 03:30)cron 會自動增量同步,不需要再手動灌。

MCP endpoint:`https://mol-open-data-mcp.<你的帳號>.workers.dev/mcp`

### 資料範圍

`wrangler.jsonc` 的 `SYNC_SINCE_YEAR` 控制只保留公告年份 >= 此值的紀錄,
預設 `"2024"`(約 22,900 筆)。設 `"0"` 表示全收。

改這個值時**要同步改 seed 的 `--since`**(在 `package.json` 的 `seed:build`),
否則 seed 灌的範圍和每日同步的範圍會不一致。

年份與累計筆數(實測):

| 起始年 | 累計筆數 |
|---|---|
| 2026 | 5,551 |
| 2025 | 14,950 |
| **2024(預設)** | **22,975** |
| 2020 | 49,234 |
| 全量 | 76,919 |

全量 76,919 筆一次灌會逼近 D1 免費版 100,000 rows written/day(主表 + FTS 索引都算),
要全量請分兩天灌,或升級 Workers Paid。

### 本機開發

```bash
npm run db:init:local
npm run seed:build && npm run seed:local
npm run dev            # endpoint 在 http://localhost:8787/mcp
```

手動觸發一次增量同步(本機 cron 不會自動跑):

```bash
curl "http://localhost:8787/cdn-cgi/handler/scheduled"
```

也可用 [MCP Inspector](https://github.com/modelcontextprotocol/inspector) 測試:
`npx @modelcontextprotocol/inspector`。

### 在 Claude 使用

Claude.ai → Settings → Connectors → Add custom connector → 貼上 `/mcp` URL。
(Claude Code:`claude mcp add --transport http mol https://.../mcp`)

## MCP 工具

| 工具 | 用途 |
|---|---|
| `search_violations` | 公司/雇主違規紀錄模糊查詢(D1 快取 + FTS5) |
| `find_datasets` | 依標籤或分類找資料集 |
| `get_dataset_info` | 資料集詮釋資料(resourceID、欄位 schema) |
| `query_data` | 查任一資源內容(filters/fields/sort/分頁) |
| `data_freshness` | 快取同步時間與筆數 |

### 模糊搜尋怎麼做的

`name_norm` 上的一般 B-tree 索引對 `LIKE '%x%'`(前置萬用字元)**完全無效**,
會變成每次查詢全表掃描。所以改用 **FTS5 `trigram`** 分詞器 —— 中文沒有空格,
只有 trigram 能做子字串比對。

trigram 的限制是**至少要 3 個字**(實測:`鼎泰` → 0 筆,`鼎泰豐` → 命中),
所以正規化後長度 < 3 的查詢改走**前綴範圍比對**,代價是只能比對名稱開頭
(查「王品」找得到「王品餐飲」,但找不到「台灣王品」)。工具回應會明講
本次用的是前綴比對,不讓模型把不完整的結果當成完整答案。

這裡有個容易踩的坑:**前綴比對不能用 `LIKE 'x%'`**。SQLite 的 LIKE 前綴
優化要求索引是 `NOCASE` collation,而預設建出來的索引是 `BINARY`,
所以 `LIKE 'x%'` 仍然全表掃描。要改成範圍比較才吃得到索引:

```sql
-- 全表 SCAN,讀 22,864 列
WHERE name_norm LIKE '王品%'
-- SEARCH USING INDEX,讀 15 列
WHERE name_norm >= '王品' AND name_norm < '王品' || char(1114111)
```

三條路徑的實測成本:

| 查詢方式 | rows_read | 免費版 5M/day 可查次數 |
|---|---|---|
| FTS5 trigram(≥3 字) | 51 | ~98,000 |
| 前綴範圍(<3 字) | 15 | ~333,000 |
| ~~`LIKE '%x%'` 全掃~~ | ~~22,864~~ | ~~218~~ |

已知漏配:查「台積電」對不到「台灣積體電路製造股份有限公司」——
俗名與登記名稱的落差沒有解,要靠商工登記資料才能對起來(見下方 TODO)。

## 上線前 TODO

- [x] ~~`/admin/sync` 加驗證~~ 已改成需要 `ADMIN_TOKEN`,未設定則回 404
- [x] ~~加 rate limit~~ `/mcp` 已加上依 IP 的 100 次/60 秒限制(見下方限制說明)
- [ ] 若要擋分散式濫用,需要 WAF 規則或 Turnstile —— Rate Limiting binding 擋不住
- [ ] 觀察各縣市欄位名稱差異,補 `sync.ts` 的 `FIELD_ALIASES`
- [ ] 加入其他法規名單(性平法等):在 `sync.ts` 的 `VIOLATION_DATASETS` 加一行
- [ ] 名稱比對進階:接經濟部商工登記把名稱對回統編,處理俗名/分公司/更名
- [ ] 上游若下架舊紀錄,增量同步不會察覺(全量重建才會)。目前觀察是累積型
      (2011 年的紀錄仍在),若之後發現會下架,需要定期重跑 seed 對帳
- [ ] `McpServer.tool()` 在目前 SDK 版本已標為 deprecated,可改用 `registerTool()`
- [ ] 考慮改用 Cloudflare 新的 stateless mcp-worker 範本

## 流量限制(以及它擋不住什麼)

`/mcp` 依來源 IP 限制 **100 次/60 秒**(Workers Rate Limiting binding,
設定在 `wrangler.jsonc` 的 `ratelimits`)。超過回 HTTP 429 + JSON-RPC
error code `-32029`。

實測結果要說清楚:

| 測試方式 | 結果 |
|---|---|
| 單一 keep-alive 連線、循序 150 次 | ✅ 第 ~100 次後開始擋 |
| 50 條並行連線、200 次 / 46 秒 | ❌ 全數放行 |

原因是計數器綁在各個 Cloudflare 節點/isolate 上,並行連線會被分散到不同
isolate、各自計數;官方文件也明講此機制是 permissive、最終一致的。
**所以它是安全閥,不是配額控制。** 服務真正撐得住是靠把查詢做便宜
(見上方三條路徑的 rows_read 對照),而不是靠這道限流。

要擋真正的分散式濫用,得用 WAF 規則或 Turnstile。

## 資料誠實性原則

- 罰鍰金額:109/6/12 勞基法 §80-1 修正前的處分未必公布金額(實測 40% 的紀錄無金額),
  查無金額時工具會明講,不讓模型自行腦補。
- 查無紀錄 ≠ 從未違規:名單揭露範圍與期間受法規限制,且本服務預設只存近年資料,
  回覆中一律附註。
- `query_data` 會告知「上游不提供總筆數」以及分頁需帶 `sort`,不讓模型誤以為
  回傳的就是全部。
- `data_freshness` 讓使用者隨時可查快取新鮮度與實際筆數。

## License

資料來源:政府資料開放授權條款(OGDL 1.0,相容 CC BY 4.0)。
程式碼:MIT(自行調整)。