前 13 天,工具都裝在自己這邊。從今天開始要把它們接到別人的東西 —— 而 MCP 就是那個接頭。
問題是這個接頭一直在變。MCP 規格 20 個月出了五版(2024-11-05、2025-03-26、2025-06-18、2025-11-25、2026-07-28),SDK 跟著改,教學文停在其中某一版,平台又是另一版。所以「MCP 支援 X」這句話現在有三個答案:規格支援、SDK 支援、你手上這支 server 支援 —— 三個常常不一樣。文件追不上實作的時候,翻更多文件沒有用,因為文件一直在變。
能回答的只有 lab。而這種 lab 便宜到過分:把設定檔拿掉,自己手打一次協定,一支 server 三行就問完。本文之後的每一次探針,都是我一句話讓 Claude 去打、把回應貼回來 —— 一個下午可以跑十幾個。今天就是這樣的一份 lab 紀錄。
MCP 的 stdio 傳輸就是 JSON-RPC over 標準輸入輸出。意思是你可以用 echo 跟它講話。
這一段可以直接照抄,不需要任何設定檔:
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| npx -y @modelcontextprotocol/server-filesystem /tmp
三行 JSON 就是完整的握手:
| 訊息 | 做什麼 |
|---|---|
initialize |
我是誰、我支援哪個版本、我有什麼能力 |
notifications/initialized |
握手完成的通知(沒有 id,不需要回應) |
tools/list |
你有哪些工具? |
第二行最容易被忽略 —— 它沒有 id,因為它是通知不是請求。規格(2025-11-25 Lifecycle)寫的是:初始化必須是第一個互動,握手成功後 client 必須送 initialized。我故意試了兩次違規:不送這行、以及把 tools/list 排在 initialize 前面 —— 這支 server 兩次都照答 14 支工具,一個錯都沒報。規格說 MUST,它不檢查。 別把這當成可以省:規格允許 server 在收到 initialized 之前不理你,換一支照規格嚴格實作的,同樣的違規就可能卡住,而你不會知道是哪一行的問題。
我這次跑回來的第一行是:
{"result":{"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":true}},
"serverInfo":{"name":"secure-filesystem-server","version":"0.2.0"}},
"jsonrpc":"2.0","id":1}
tools/list 回了 14 支工具:read_file、read_text_file、read_media_file、read_multiple_files、write_file、edit_file、create_directory、list_directory、list_directory_with_sizes、directory_tree、move_file、search_files、get_file_info、list_allowed_directories。
這個數字是量出來的,不是從文件抄的。 這個習慣明天就會用到 —— 接自己的資料庫,第一步也是先數它到底給了幾支。
initialize 裡那個 protocolVersion 看起來很像形式主義。所以我送了三個不同的值:
| 我送的版本 | 它回的版本 | 這代表什麼 |
|---|---|---|
2025-06-18 |
2025-06-18 |
接受 |
2024-11-05(舊版) |
2024-11-05 |
它也支援這版,照規格回同一版 |
2999-01-01(不存在) |
2025-11-25 |
不支援 → 回它自己支援的一版,規格說 SHOULD 是最新版 |
三個結果讓我確認了兩件事:
第一,協商是真的,不是照抄。 如果它只是把我送的值回給我,第三列應該回 2999-01-01。它沒有 —— 它認出這個版本它不認識,然後回報自己實際支援的最上限。這兩種協商結果都是規格明文允許的:支援就回同一版(MUST),不支援就回另一版而且應該是最新版(SHOULD);規格另外還允許直接回一個 Unsupported protocol version 的錯誤,這支 server 選了前者。
第二,2025-11-25 是它的天花板。 這個數字你在 initialize 送對版本的時候永遠看不到,只有送一個它不認識的版本,它才會把底牌亮出來。
這是一個通用的 probe 技巧:想知道對方支援到哪裡,送一個明顯超過的值,看它回退到哪。比讀文件可靠,因為文件寫的是「應該」,回退值是「實際」。
規格 2026-07-28 那一版加了一個 server/discover,規定 server 必須實作,用來在任何請求之前先問它支援哪些版本、有哪些能力。我試著呼叫它:
{"jsonrpc":"2.0","id":9,"error":{"code":-32601,"message":"Method not found"}}
-32601 是 JSON-RPC 的標準錯誤碼「方法不存在」。
規格裡有,這個 server 沒實作。 這不是 bug —— 規格演進和實作跟進之間本來就有時間差;上一節探出來的天花板 2025-11-25 剛好就是 2026-07-28 的前一版,兩個數字對得上。但它示範了一件事:「MCP 支援 X」這句話要問清楚是規格支援還是你手上這支 server 支援,而分辨的方法就是打一次看回什麼。
順帶一提,2026-07-28 那版改的不只這個:它把 initialize/notifications/initialized 這組握手整個拿掉了,改成每個請求自己在 _meta 裡帶版本和能力。所以上面那三行 JSON 是 2025-11-25 以前的協定長相 —— 一支只講新版的 server,握手那兩行它會不認得(規格的相容矩陣:Legacy client × Modern server = Fails);兩種都講的則兩種都接。今天示範的握手已經在退場了;但探它到底是哪一版的方法不會。
還有一個小線索藏在 stderr 裡。這支 server 起來時會印一行:
Client does not support MCP Roots, using allowed directories set from server args
「Roots」是 client 告訴 server 它能碰哪些目錄的機制 —— 而它正是 2026-07-28 那版列入廢棄的三個功能之一(Roots、Sampling、Logging)。同一支 server,一邊回報自己支援到 2025-11-25,一邊還在用下一版已經宣告要淘汰的東西。這不矛盾,這就是「規格支援」和「這支 server 支援」之間的時間差長什麼樣。
2026-07-28 不是遠方的規格。Claude Code 從 2.1.232 起換了一套 MCP client runtime(官方叫 v2,底層是 MCP TypeScript SDK 2.0),它會講新協定。但它對不同的 server 態度不同:HTTP server 它本來就會問對方支不支援新版;claude.ai 的 connector 只在部分 session 問;stdio server 預設不問 —— 環境變數 MCP_PROTOCOL_NEGOTIATION 沒設時是 legacy,照舊走 initialize 握手,設成 auto 才問(connector 也會變成每個 session 都問)。
我拿自己 Day 13 那支檢查器(Python,uv run --with mcp 每次抓最新 SDK)測了兩種設定,看 debug log 裡它跟誰講了哪一版:
| 設定 | 我的 stdio server | claude.ai 內建的 Gmail/Calendar/Drive |
|---|---|---|
預設(legacy) |
2025-11-25,era = legacy |
2025-11-25,era = legacy |
MCP_PROTOCOL_NEGOTIATION=auto |
2026-07-28,era = modern |
2025-11-25,era = legacy |
三件事值得看。第一,我那支 server 什麼都沒改,就已經是「雙時代」的了。我把 server 用 tee 包一層,抄下 Claude Code 在兩種設定下實際送出的每一句:
預設(legacy) |
auto |
|
|---|---|---|
| 第一句 | initialize(protocolVersion: 2025-11-25) |
server/discover(_meta 帶 2026-07-28、clientInfo、clientCapabilities) |
| 第二句 | notifications/initialized |
subscriptions/listen |
| 接著 | tools/list、prompts/list、resources/list |
同樣三支,但每一句的 _meta 都自帶版本與身分 |
| server 對第一句的回應 | 握手結果 | DiscoverResult:supportedVersions: ["2026-07-28"]、能力、instructions |
tools/list 的回應 |
工具清單 | 工具清單,多了 resultType、cacheScope、ttlMs |
握手那兩行在 auto 那邊整個消失,換成每個請求自帶身分 —— 這就是「無握手」在線上的長相。同一支 server(Python SDK 2.2.0)兩種都接,我一個字沒改。
第二,我第一次打它的時候判斷錯了。 我手打 server/discover 給它,它回 -32601,我差點寫下「官方 SDK 沒實作這個 MUST」。看了 Claude Code 送的那一句才發現差在哪:我的探針沒帶 _meta。補上去再打三次:
我送的 server/discover |
它回的 |
|---|---|
沒有 _meta |
-32601 Method not found |
_meta 只帶 protocolVersion |
-32602 params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities |
_meta 帶齊版本與 clientCapabilities |
DiscoverResult,supportedVersions: ["2026-07-28"] |
規格寫得很清楚:雙時代 server 看你怎麼開場決定用哪個時代 —— 帶 _meta 的請求走新版,initialize 走舊版。一句沒帶 _meta 的 server/discover,在它眼裡是舊時代的請求在叫一個舊時代沒有的方法,回 -32601 完全正確。錯的是探針,不是 SDK。(上一節那支 filesystem server 我也補打了帶 _meta 的版本,仍然 -32601 —— 它是真的舊。)
第三,Anthropic 自家的三個 connector 是它本來就會問的那種,兩種設定下都被問了,也都回舊版 —— 名字還叫 StatelessServer。在我看到的這幾支裡,順序是規格先走、SDK 跟上、平台自己最後。
同一支 server、同一個工具、同一句話,兩種設定各跑三次,要 Claude 把工具回傳的 JSON 原封不動印出來:六份輸出 md5 完全相同,都是 2,282 bytes。.mcp.json 一個字沒改。你唯一看得到差別的地方是 --debug-file 的 log:protocolEra 一個寫 legacy、一個寫 modern;連線時間一個約 290ms、一個約 570ms,三次都穩定 —— auto 多了 server/discover 那一句要等。
所以「使用者端沒什麼改變」是對的,而且是規格刻意的:相容矩陣裡只要有一邊是雙時代就是「Works」,而 Claude Code 從 2.1.232 起就是雙時代 client。唯一要留意的是:預設 legacy 時它對 stdio server 只會送 initialize,哪天有人給你一支只講新版的 server,照規格它會拒絕那句握手 —— 那時候就是該設 auto 的時候(這一格我沒有實測,是照規格推的)。
對兩種人,各一句:
auto,然後開 --debug-file 看 log 裡每支 server 的 negotiatedProtocolVersion 和 protocolEra。server/discover(上面抄到的第一句),規格也允許直接送一個 _meta 裡帶版本的請求。你兩種都不認得的話:前者你回 -32601,雙時代的 client 會退回舊握手;後者你會當成一個沒握手就來的請求處理掉,行為不定義。而只講新版的 client,哪一種開場都不會退回舊握手,直接放棄。規格的相容矩陣裡「Legacy server × Modern client = Fails」那一格,指的就是這種 server。所以本文那個「送一個它可能不認得的方法,看它回什麼」的探針,剛好就是新版規格寫進去的相容機制:stdio 上先送 server/discover,回 DiscoverResult(或新版定義的錯誤)的是新 server,回其他任何錯誤 —— 像 -32601 —— 或逾時沒回的,就是舊 server。 我一開始拿它當偵錯技巧,規格拿它當正式的判別方法。
而我自己就示範了這個判別法怎麼用錯:探針沒照新版規格帶 _meta,探出來的結論就是反的。探針也是請求,規格對請求的要求,它一條都不能少。 文件不會提醒你這件事,因為文件假設你會照著做;只有把自己打的跟 client 打的並排看,才看得出差在哪。
手打 JSON-RPC 平常沒有必要 —— 有設定檔就好。它的價值在出事的時候。
當你的 MCP server 接不上,client 端給你看的錯誤訊息往往只有一句「connection failed」。這時候有兩條路:
第二條路會直接告訴你:server 有沒有起來、握手停在哪一步、它認為自己有幾支工具。這三個資訊通常就能定位到問題在哪一層。
把本文開過的 lab 對一下帳 —— 左邊是文件說的、或我以為的,右邊是打了之後它說的:
| 文件說/我以為 | lab 說 |
|---|---|
規格:server 收到其他請求之前 MUST 先收到 initialized |
server-filesystem 不檢查,順序反了照答 14 支 |
規格:server MUST 實作 server/discover |
filesystem 回 -32601,它是真的舊;我自己那支第一次也回 -32601 —— 錯的是我的探針沒帶 _meta |
規格 2026-07-28:Roots 列入廢棄 |
同一支 server 的 stderr 還在用 Roots |
| Claude Code 文件:2.1.232 起會講新協定 | 預設對 stdio 不講,設 auto 才講;Anthropic 自家 connector 兩種設定都回舊版 |
| 我以為:協定換版,用的人會有感 | 六次輸出 md5 全同,差別只在 debug log |
五列沒有一列是靠讀文件讀出來的。打過一次的好處就在這裡 —— 當 server 回報的東西跟你以為的不一樣時,你會知道。這在後面幾天會變成關鍵。
這一篇留下的心法:
「支援 X」有三個答案:規格說的、SDK 做的、你手上這支回的 —— 最後一個要打一次才知道。文件會變,lab 不會 —— 有疑問就把設定檔拿掉,手打一次。
明天:從頭到尾接一支 MCP server —— 選、掛、驗、問,到它第一次撞牆。
2025-11-25 規格 — Lifecycle(本文那三行握手的定義:initialize → notifications/initialized):modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle
2025-11-25 規格 — Transports(stdio 就是「一行一個 JSON-RPC 訊息」):modelcontextprotocol.io/specification/2025-11-25/basic/transports
@modelcontextprotocol/server-filesystem):github.com/modelcontextprotocol/servers/tree/main/src/filesystem
MCP_PROTOCOL_NEGOTIATION;connector 只在部分 session 協商那句以 2026-09-24、Claude Code 2.1.281 的版本為準):code.claude.com/docs/en/mcp
2026-07-28 規格 — Versioning and Compatibility(無握手的版本協商、server/discover、雙時代相容矩陣):modelcontextprotocol.io/specification/2026-07-28/basic/lifecycle
2026-07-28 變更紀錄(server/discover 新增、initialize 握手移除、Roots/Sampling/Logging 列入廢棄;2026-09-23 查閱):modelcontextprotocol.io/specification/2026-07-28/changelog
-32601 的定義):www.jsonrpc.org/specification
npx -y @modelcontextprotocol/server-filesystem,npm 上的版本是 2026.8.31(2026-08-31 發布,至今仍是 latest);而它 initialize 自報的是 secure-filesystem-server 0.2.0 —— 原始碼裡那個 version 從 2024 年就沒再動過。同一支 server,兩個版本號,這本身就是本文那句「要問清楚是誰支援」的又一個例子。macOS;2026-09-03 首測,2026-09-13 全部重跑一次結果相同,上面每一條指令都可以自己重跑