iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 15 篇

Day 15 接一個 MCP server 的完整流程:從 .mcp.json 到它第一次撞牆

  • 分享至 

  • xImage
  •  

昨天手打三行 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

選 server:第三支才起來

選 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,一樣掛在同一行。釘版這件事寫進設定檔,不是寫在腦子裡。)

二、掛:一個檔,三個 scope

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,沒有人會攔」。

三、驗:三行握手、四句 SQL、一行 log

握手在選 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,矩陣那一格今天有了一個真的例子。

四、問:同一筆逾期,一次算 5 天、一次算 4 天

接上去之後,我用 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」在這個版本根本無法持久寫入 —— 唯讀比它自己以為的還硬。

五、換一個 client:同一支 server 掛給地端模型

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 自己的開檔模式。

把它寫成可執行的規則,而不是習慣:

  • 只接副本,不接線上 —— 弄壞了重新匯出就好
  • 副本先清掉個資欄位的值、刪掉 session 表,再掛
  • MCP server 掛唯讀,而且是開檔模式那種,不是工具參數那種
  • Bash 對這個檔案的寫入路徑,在 permissions 裡另外擋;sandbox 擋不到 MCP,別指望它

四條都要落成可執行的限制,不是自律。第三句的回報再老實,也不能拿來當防線。


總結

六步各留一句:

  • 選:能接副本就不接線上;server 第三支才起來,前兩支死得沒聲音
  • 掛:一個 .mcp.json,Project scope 進版控,所以第一次要你點頭;-p 不會問
  • 驗:先手打三行、再送四句 SQL、最後看一行 log —— 三個角度看同一支 server
  • 問:答案再完整也要看它送的 SQL —— 兩種問法都說「有逾期」,心算的那次對、寫 SQL 的那次把「現在」寫成了日期;地端在 SQL 裡犯同一個錯
  • 換 client:同一支 server 掛給地端模型,改個路徑就接上 —— 協定的承諾兌現了,但接得上不等於答得對;設定會被關掉,要驗
  • 邊界:副本、剝乾淨、唯讀、Bash 另外擋 —— 四條都不靠自律;sandbox 管不到 MCP,Day 4 驗過

接上去只是開始。MCP 讓 Claude 看得到資料,看得到之後它怎麼解讀,才是接下來要盯的。

這一篇留下的心法:

接 MCP 的每一步都是設定,不是自律 —— 而答案再完整,也要看它送出的 SQL。

它撞牆之後把 UPDATE 寫好交給你 —— 這就是設定要比自律多一條的原因。

明天:裝一個 AI 工具之前,怎麼知道它動了什麼 —— 一個三萬多星的工具,--dry-run 說會動 18 個地方,實際動了 39 個。


參考資料


上一篇
Day 14 MCP 支援 X?規格、SDK、你手上這支,三個答案要打一次才知道
下一篇
Day 16 裝一個 AI 工具之前,怎麼知道它動了什麼
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言