昨天手打三行 JSON-RPC 看完了協定握手。今天從頭到尾接一支:選、掛、驗、問、換 client、圈邊界,六步每一步都有一個「跑過才知道」的東西。對象是一個 sqlite 檔 —— 我用圖書館借閱系統的 schema 灌了四張表、18 列樣本(books 5 本、users 4 位讀者、loan_policies 1 條借期政策、borrow_records 8 筆借閱紀錄),資料是假的,但它送出的 SQL、回的錯誤和答案都是真的跑出來的。
為什麼是資料庫:它是很典型的「想讓 Claude 看得到、但絕對不能讓它改壞」的東西。接公司的正式庫時,會遇到同樣的決定,只是代價更大。
如果資料在 Cloudflare D1 上(後 10 天那個專案就是),有兩種接法:
| 方案 | 怎麼運作 | 判定 |
|---|---|---|
| A. Cloudflare 官方 MCP | 直接連遠端 D1 | 🟡 資料是線上生產資料,一個手滑的 DELETE 就真的沒了 |
| B. 本地 replica + sqlite server | wrangler d1 export 拉一份下來,接通用 sqlite MCP server |
⭐ 採用 |
選 B 的理由不是它比較好用,是它比較難闖禍。
D1 底層就是 SQLite,匯出一份本地檔之後,任何查詢最壞的結果就是弄壞那份副本,重新匯出就好。而且這條路還有一個附帶好處:MCP server 用唯讀模式掛上去,連弄壞副本的可能性都拿掉。
代價是資料有延遲 —— 我查到的是匯出當下的快照,不是此刻。對這篇要問的問題,延遲幾分鐘完全不影響。
這篇的樣本是直接灌的;下面這兩行照官方文件寫,是真的接 D1 時的做法:
# 拉一份本地副本:export 出來的是 SQL dump,不是 sqlite 檔,要自己灌進去
npx wrangler d1 export library --remote --output ./local/library.sql
sqlite3 ./local/library.sqlite < ./local/library.sql
選 sqlite MCP server 這件事我以為五分鐘,結果第三支才起來 —— 而且前兩支從 Claude Code 看都死得沒有聲音:
| 試的 server | 發生什麼 | 原因 |
|---|---|---|
mcp-sqlite-server(npx,TypeScript) |
npx 跑完沒有任何輸出,handshake 沒回 |
它依賴的 better-sqlite3 11.10 在 Node 26.5 沒有預編譯檔,改走 node-gyp 編譯又失敗;npx -y 把這整段吞掉了 |
mcp-sqlite 0.3.2(uvx,Python) |
stdout 一樣是空的 | stderr 裡是 AttributeError: 'Server' object has no attribute 'list_tools' —— MCP Python SDK 2.0 把這個 API 拿掉了,跟昨天講的同一次改版 |
同一支加 --with "mcp<2" |
握手成功:SDK 1.30.0、協定 2025-11-25、兩支工具(sqlite_get_catalog、sqlite_execute) |
鎖回 1.x 就好 |
兩支都是「起不來」,但一支卡在 Node 的原生模組、一支卡在 Python SDK 的破壞性變更;對 Claude Code 來說,最後都只剩一個 connection failed。昨天那三行的用途就是這個 —— 不是拿來學協定,是拿來把「起不來」分成兩種。
(發文前重打了一次:mcp-sqlite 還是 0.3.2,不釘版拉到的 SDK 已經是 2.2.0,一樣掛在同一行。釘版這件事寫進設定檔,不是寫在腦子裡。)
MCP server 掛進 Claude Code 有三個層級,差別只在「存在哪、誰看得到」:
| Scope | 存在哪 | 誰看得到 | 怎麼加 |
|---|---|---|---|
| Local | ~/.claude.json |
只有我,只在這個專案 | claude mcp add(預設) |
| Project | 專案根目錄的 .mcp.json |
進版控,整個團隊 | claude mcp add --scope project,或直接寫檔 |
| User | ~/.claude.json |
只有我,所有專案 | claude mcp add --scope user |
這支我選 Project —— 設定跟著 repo 走,而且檔案就這幾行,直接寫比下指令清楚:
{
"mcpServers": {
"library-db": {
"type": "stdio",
"command": "uvx",
"args": ["--with", "mcp<2", "--from", "mcp-sqlite", "mcp-sqlite", "./local/library.sqlite"]
}
}
}
三個欄位:type 是傳輸方式(本機程序就是 stdio,昨天手打的就是這種);command 加 args 就是你在終端機會打的那一行,拆開來寫;--with "mcp<2" 是上一節那個坑的解法,寫在設定檔裡,下次 clone 的人不用再踩一次。需要密鑰的 server 可以加 env,值寫 ${VAR} 從環境變數展開,設定檔本身不放明文。
有一件事文件寫了、我跑了才體會到:Project scope 的 server 不會自動連。 因為 .mcp.json 進版控,等於 clone 一個 repo 就可能拿到一支會跑任意程式的設定;所以第一次在互動模式開 Claude Code,它會問你「這個專案設定了 MCP server,要啟用嗎」,你點了才連。我在還沒核准之前跑 claude mcp list,它給的狀態是:
library-db: uvx --with mcp<2 --from mcp-sqlite mcp-sqlite ./local/library.sqlite - ⏸ Pending approval (run `claude` to approve)
核准記在本機的設定檔,不進版控;反悔用 claude mcp reset-project-choices。而我這次實測,claude -p 這種非互動模式會略過核准直接連 —— 寫腳本的時候要知道這件事,它的意思是「你在 CI 裡放的 .mcp.json,沒有人會攔」。
握手在選 server 那張表裡已經打過了(第三支回 2025-11-25、兩支工具)。起來之後,唯讀也要打一次才算數。四句 SQL 送進 sqlite_execute:
| 送的 | 回的 |
|---|---|
SELECT COUNT(*) FROM books |
5,正常 |
INSERT INTO books … |
attempt to write a readonly database |
DELETE FROM books |
同上 |
PRAGMA query_only |
0 |
最後那列是重點:query_only 是 0,至少排除了它是靠 PRAGMA query_only=1 擋寫入;再對照它的原始碼(連線字串是 mode=ro),擋住的是開檔時就用唯讀模式。事後 sqlite3 數列數,還是五列。
另外一件事值得記:我第一支試的那個 server,README 寫「read-only by default」,但它的唯讀是 query 工具的一個參數,readonly=false 就開了 —— 而工具參數是模型填的。那不是唯讀,那是請模型自律。mcp-sqlite 的唯讀是 server 開檔的方式:一般查詢一律以 mode=ro 開檔;唯一例外是 metadata 檔裡事先寫死、標了 write: true 的 canned query —— 模型可以呼叫它,但改不了裡面的 SQL,也不能自己加一條。所以這篇後面寫的「唯讀」,指的是這種 server 層級的唯讀。
最後從 Claude Code 這一端看它。開 --debug-file,log 裡對這支 server 只需要看一行:
MCP server "library-db": Connection established with capabilities: {…"serverVersion":{"name":"mcp-sqlite","version":"1.30.0"},"protocolEra":"legacy","negotiatedProtocolVersion":"2025-11-25"}
233ms 連上,工具呼叫 8ms。順便把昨天那張相容矩陣拿來用一次:這支 server 是真的舊(SDK 1.x,只會 initialize 握手),我把 Claude Code 設成 MCP_PROTOCOL_NEGOTIATION=auto 讓它先探新版 —— 它退回舊握手,照樣連上,log 裡還是 legacy。Dual-era client × Legacy server = Works,矩陣那一格今天有了一個真的例子。
接上去之後,我用 claude -p 問它,工具只開 sqlite_get_catalog 和 sqlite_execute 兩支,權限模式是預設的(沒開 Bash,任何其他工具會被拒 —— 這個限制後面會變成重點)。每一句都記三樣:它叫了什麼工具、送了什麼 SQL、答案對不對。
樣本裡埋了一筆逾期:《MCP 協定入門》王大同借的,due_at 是 2026-09-12T10:00:00Z,我發問的時候是 9 月 17 日 12 點多(UTC),過期 5 天又 2 小時。規格的逾期天數只有一種算法:floor((now − due_at) / 86400 秒),純秒差,不看日曆 —— 所以正確答案是 5。另一筆《Hono 與 Workers》due_at 是 2026-09-17T00:10:00Z,過了 12 小時,按同一條規則,逾期天數算 0 天。
第一句:「這個資料庫有哪些表、各幾列?」
先 sqlite_get_catalog,再一句 UNION ALL 的 COUNT(*),回 8/5/4/1,順便把每張表的欄位列出來。對。這句是暖身,看它會不會先看目錄再動手 —— 會,工具描述裡寫了「Call this tool first!」,它照做。
第二句,先用不點名的問法:「列出每本書現在的狀態;借出中的書,是誰借走的。」
一句 LEFT JOIN:書 ⟕ 未歸還的紀錄 ⟕ 讀者,順手把 due_at 也撈了出來。五本書的狀態、三個借閱人,全對。然後 —— 沒人問它逾期 —— 它看著表裡的日期,在王大同那列標了「已逾期 5 天」,在李志豪那列標了「今天到期」。
5 是對的。但看它送的 SQL 就知道,這個 5 不是算出來的:SQL 裡沒有任何時間比較,它是看到 09-12 和 09-17 之後心算的。心算看的是日期不是時刻 —— 如果 due_at 是 12 日 23 點,照日期算是 5,規格算是 4。
同一句,改成點名的問法:「哪些書現在借出中?有沒有已經逾期還沒還的?各逾期幾天?」
這次它在 SQL 裡算了:
CAST(julianday('2026-09-17') - julianday(br.due_at) AS INTEGER) AS days_overdue_calc
回答:《MCP 協定入門》逾期 4 天。錯了一天,而且錯得很有規律 —— 那個 '2026-09-17' 是它自己知道的「今天」,沒有時間,julianday 拿到的是當天零點;零點減 12 日 10 點是 4.58 天,砍成整數是 4。規格的「現在」是一個時刻,它寫成了一個日期,而且不是資料庫的 now,是它腦子裡的今天。它倒是老實補了一句:「overdue_days 欄位對這三筆都還是空的,逾期天數是我用 due_at 跟今天日期算出來的。」
兩種問法,一次 5 天、一次 4 天,說明的是同一件事:答案的完整度看不出對錯,只有它送的 SQL 看得出來。 不點名,它心算,這次算對;點名,它寫 SQL,卻把「現在」寫錯了。兩次答案都很完整、都附了算法,光看回覆,你會兩次都點頭。這是為什麼每一題都要記它送的 SQL。
第三句:「把《SQLite 權威指南》下架。」
這句是故意的,而且故意選了一本借出中的。它查了狀態、查了借閱人、查了 status 有哪些值,然後送了一句我沒料到的 SQL:UPDATE books SET status = status WHERE id = 1 —— 一句什麼都不改的 UPDATE,拿來探連線能不能寫。server 回 attempt to write a readonly database。它的回覆:沒辦法下架,兩個原因,一是唯讀,二是這本書林小明借走了、24 日才到期;另外表裡沒有「下架」這個狀態值,要用哪個字你得先說。最後問我兩件事才肯動。
它擋下自己的理由裡,唯讀排第一,業務常識排第二。這不是我要測的東西,所以換一本在架上的再問一次。
第三句,換一本再問:「把《圖書館學概論》下架。」
這次它查完沒有人借、送了 UPDATE books SET status = 'withdrawn' WHERE id = 4 AND title = '圖書館學概論' —— 同一道牆。它的回報很老實:第一句就說「沒辦法下架,這個 MCP 是唯讀的」,把想跑的 SQL 貼給我,還註明 withdrawn 這個值表裡沒有前例。
然後它提了兩條路:一是把 server 加一個 write: true 的 canned query;二是「直接在資料庫端執行」,後面跟著那句寫好的 UPDATE。
第二條路就是這篇最後一節要講的東西。那句 SQL 它已經寫好了,這次是我來跑,只因為它沒有 Bash;檔案路徑就寫在 .mcp.json 裡,跟它同一個目錄。唯讀擋住的是 MCP 這條路,不是那個檔案。
第一條「加可寫 canned query」的路我也試了,結果比較意外:mcp-sqlite 0.3.2 的 write: true canned query,呼叫回 Statement executed successfully,但下一次呼叫的 SELECT(server 每次呼叫都重開一條連線)是空的,事後用 sqlite3 開檔也是空的。翻它的原始碼,execute() 用 mode=rw 開了連線、執行、沒有 commit(),連線一關就 rollback。所以「加一個可寫的 canned query」在這個版本根本無法持久寫入 —— 唯讀比它自己以為的還硬。
MCP 最大的承諾是 client 無關 —— server 寫一次,誰都能接。這句話值得實測一次:把同一支 mcp-sqlite、同一個 sqlite 檔,掛給 LM Studio 裡的地端模型(qwen3.6-35B-A3B,Day 19 會細講它),上面那幾句再問一遍。
設定幾乎是照抄。LM Studio 的 ~/.lmstudio/mcp.json 長得跟 Claude Code 的 .mcp.json 一樣,只差路徑要寫絕對的;然後從它的 API 打,integrations 指到那支 server:
curl http://127.0.0.1:1234/api/v1/chat -H "Authorization: Bearer $LM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3.6-35b-a3b-mlx","input":"…","integrations":["mcp/library-db"]}'
有一個小門檻,而且它剛好是這篇的主題:LM Studio 把「API 能不能呼叫 mcp.json 裡的 server」做成 token 的權限,要先開 Require Authentication、建一個 token、勾「Allow calling servers from mcp.json」才行;關著打過去是 403 Permission denied to use plugin。不是自律,是設定 —— 別人也這樣想。
| 問 | 地端模型(qwen3.6-35B-A3B,14~33 秒) | 跟 Claude 那次比 |
|---|---|---|
| 有哪些表、各幾列 | 先 sqlite_get_catalog,再一句 UNION ALL,8/5/4/1,對;但它把欄位數也一起報了,用詞混著「列」和「行」 |
一樣(SQL 幾乎同一句) |
| 每本書狀態、誰借的(不點名) | 一句 LEFT JOIN,只撈書名、狀態、人名,沒撈日期 |
三本借出中、誰借的,都對;但它沒有可以心算的日期,所以逾期那本什麼都沒標 —— Claude 是撈了日期才順手看到的 |
| 借出中、逾期、幾天(點名) | 在 SQL 裡寫了 julianday(date('now')) - julianday(due_at) —— 一樣 4 天 |
同一個錯:date('now') 沒有時間,跟 Claude 寫死 '2026-09-17' 是同一件事。兩個模型、兩種寫法、同一個「現在 = 日期」的假設 |
| 下架《SQLite 權威指南》(借出中) | 沒先查,直接 UPDATE books SET status = 'unavailable' WHERE title = '《SQLite 權威指南》'(書名號一起塞進字串,本來就對不到),撞到 attempt to write a readonly database,回「唯讀模式,無法執行」 |
Claude 先查了借閱狀況、送的是一句無害探針;地端先送再說,也沒看到那本書有人借 |
| 下架《圖書館學概論》(在架上) | 查 id、送 UPDATE … 'offline'、撞牆、老實回報,貼一句 SQL「請在允許寫入的環境執行」 |
同 Claude 的第二條路;但地端這句只能是給人的 —— LM Studio 的對話裡只有那兩支 MCP 工具,沒有 shell,sqlite3 這條路對它不存在 |
三件事從這張表看出來。第一,MCP 的承諾是真的:.mcp.json 照抄、只把路徑改成絕對,server 程式一行沒動,兩個 client 都接上同一支 server。第二,接得上跟答得對是兩回事 —— 同一個問題、同一份資料,兩個 client 在 SQL 裡犯同一個錯(「現在」寫成日期)。這是模型的事,不是協定的事,所以「每一題記它送的 SQL」在哪個 client 都不能省。第三,下一節那條「Bash 另外擋」的規則,對地端這個 client 不需要 —— 但理由是它沒有手,不是它不會伸手。
順帶一個小插曲:這五題第一次打過去全部 403。查了才發現 LM Studio 的 Require Authentication 不知道什麼時候被關掉了 —— 關掉之後隨便什麼 token 都回 200,但 mcp.json 裡的 server 一律不給用。重新打開才正常。「不是自律,是設定」這句後面要補半句:設定也會被關掉,所以要驗。
讓 AI 直接查資料庫,有兩個問題必須在接上去之前就決定:它看得到什麼、它碰得到什麼。
看得到什麼:這次的樣本只有四張表,users 只有 id 和 name —— 這個練習題的規格刻意寫成這樣,沒什麼好剝的。但真的圖書館系統不會這麼乾淨:讀者表會有 email、電話,多半還有密碼雜湊或一張 session 表,而 wrangler d1 export 這類工具是整份倒出來的,不會幫你挑。「這個系統本來就沒有個資」是我一開始想寫的句子,但這種事不能用假設的。副本要先剝乾淨再掛,大概長這樣(這幾個欄位這個練習題沒有,正式庫才有):
DROP TABLE sessions;
UPDATE users SET email = NULL, phone = NULL, password_hash = NULL;
碰得到什麼:第三句已經示範過了。MCP 唯讀擋得住 UPDATE,但模型知道檔案在哪。只要它有 Bash,一行 sqlite3 local/library.sqlite "UPDATE …" 就繞過去。所以唯讀不是一條規則,是兩條 —— MCP 這條路用 server 的開檔模式擋,Bash 那條路用 Day 10 的 settings.json 擋(Bash(sqlite3:*) 進 deny)。
那反過來呢 —— Day 4 講的 Bash sandbox(/sandbox)開著,能不能替 MCP 這條路擋?官方文件說它只管 Bash 和子程序,MCP server 不在裡面;Day 4 已經驗過一次:sandbox 開著,Bash 對 cwd 外寫檔回 operation not permitted,同一個 session 裡一支 20 行的 MCP server 照樣把檔案寫進去。所以 sandbox 是 Bash 那條路的第二道鎖,管不到 MCP —— MCP 這條路,這次實際擋住寫入的是 server 自己的開檔模式。
把它寫成可執行的規則,而不是習慣:
四條都要落成可執行的限制,不是自律。第三句的回報再老實,也不能拿來當防線。
六步各留一句:
.mcp.json,Project scope 進版控,所以第一次要你點頭;-p 不會問接上去只是開始。MCP 讓 Claude 看得到資料,看得到之後它怎麼解讀,才是接下來要盯的。
這一篇留下的心法:
接 MCP 的每一步都是設定,不是自律 —— 而答案再完整,也要看它送出的 SQL。
它撞牆之後把
UPDATE寫好交給你 —— 這就是設定要比自律多一條的原因。
明天:裝一個 AI 工具之前,怎麼知道它動了什麼 —— 一個三萬多星的工具,--dry-run 說會動 18 個地方,實際動了 39 個。
.mcp.json 格式與 ${VAR} 展開、專案層核准提示、/mcp 與 claude mcp list):code.claude.com/docs/en/mcp
wrangler d1 export 輸出 SQL dump 的說明):developers.cloudflare.com/d1/best-practices/import-export-data
mcp-sqlite(本文採用的 sqlite MCP server,Python;0.3.2 需 mcp<2;write: true 沒有 commit 的那段在 server.py 的 execute()):github.com/panasenco/mcp-sqlite(0.3.2 那一版的 server.py:tag 0.3.2)mcp-sqlite-server(試過、在 Node 26.5 裝不起來的那支;唯讀是工具參數):github.com/ofershap/mcp-server-sqlite
integrations 欄位、mcp.json server 需 token 權限):lmstudio.ai/docs/developer/core/mcp
qwen3.6-35b-a3b-mlx、temperature: 0,2026-09-17;五題各 23/15/30/33/14 秒,76~83 tok/sbooks / users / loan_policies / borrow_records,一書一冊),時間欄位存 UTC 的 ISO 8601(…Z)