你的 client 開場第一句話是 initialize。某天對面把 server 升上去,這句話回來一個錯誤,你拿著那個 code 去翻規格想查它是什麼意思,查不到。

不是文件漏寫。2026-07-28 這一版的 Versioning 頁在相容矩陣裡把 Legacy client 對 Modern server 那一格標成 Fails,然後補了一句:the exact code is implementation-defined。規格決定不管它。

握手被拿掉這件事本身沒什麼好講的,真正花時間的是下一步。你的 client 要同時吃得下新舊兩種 server,而「判斷對面是哪一種」這件事,比想像中難寫。

兩條看起來很自然的路,走到一半會斷

第一個念頭通常是:不管它,直接送一個 modern 請求過去,成功就是新的,失敗就是舊的。

stdio 上這招會壞在「失敗」的定義。規格把 Modern client 對 Legacy server 的結果寫得很清楚:The server may reject the request with an implementation-defined error, stay silent, or even process an era-ambiguous method under legacy semantics.

三種反應。前兩種難看,但至少誠實:回一個你看不懂的錯,你知道出事了;乾脆不回,你等到 timeout 也知道出事了。探針碰上這兩種頂多是慢,判斷不會錯。

第三種是另一回事。它照舊語意把一個時代模糊的方法處理掉了,然後回你一個成功。

你的探針收到成功,判定對面是新的,接下來整條連線都用新協定跟它講話——而剛才那一次呼叫,做的其實是舊語意的那件事。這裡不是有人把錯誤吞掉了,是根本沒有錯誤產生:沒有錯誤碼可以查,沒有 timeout 可以計時,log 裡就是一行成功。等你察覺,多半是因為結果不對勁,而不是因為協定通知了你。

一個會沉默、又會給你假陽性的東西,不能當探針用。

第二個念頭換到 HTTP。那我看狀態碼總行了吧,400 就 fallback 回 initialize

這招壞在 400 不是舊 server 的專利。規格的 Backward Compatibility 段直接點名:modern servers also use 400 for UnsupportedProtocolVersionError, MissingRequiredClientCapabilityError, and header-validation failures. 一台新 server 只是嫌你版本太舊、或是你少帶了一個必填欄位,回的也是 400。你照狀態碼就退回去,等於把它誤判成舊的,然後用舊協定跟它講一整天話。

所以規格要你多做一步:the client SHOULD inspect the response body before falling back。判準統一成同一句。body 裡是一個認得出來的 modern JSON-RPC 錯誤,對面就是新的,該做的是換版本重試;空 body、不認得的錯、或是等到 timeout,才算舊。

stdio 沒有狀態碼可以看,所以它走另一條。規格說 clients SHOULD send server/discover first to fail deterministically,用一個新版一定有、舊版一定沒有的方法去敲,讓失敗變成確定的。

server 必須做,client 可以不叫

server/discover 這個 RPC 有一組不對稱寫得很刻意:Servers MUST implement it,Clients MAY call it。

停在這裡想一下。如果 client 那邊也是 MUST,它就只是換了名字的 initialize,你還是得先打一發才能開工,差別只是會話狀態從 server 記變成 client 記。規格把 MAY 留給 client,保住的是「任何一個 RPC 都可以直接打」這件事:你想第一句就送 tools/call 就送,版本不對再處理。

我的立場是別把它當成新的開場白。每次連線都先來一發 discover,等於把剛拆掉的握手自己裝回去。

什麼情況下我會改口,規格自己列了兩個。一個是你要在畫面上先呈現這台 server 是誰、支援什麼。一發 discover 就拿得到 identity、capabilities 跟支援版本,比 tools/listprompts/listresources/list 各打一次划算。另一個是你的 client 要同時吃新舊 server、而且走 stdio,那就照上面那條 SHOULD 先送 discover。

順帶一提,discover 的回應本身支援快取,結果裡帶 ttlMscacheScope,所以「先打一發」的成本不一定每次都得付。

請求長這樣,除了標準的 _meta 沒有別的參數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "ExampleClient",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

回來的 result 裡你要的是 supportedVersions(挑一個當之後的版本)跟 capabilitiesserverInfo 放在回應的 _meta 底下,規格特別註明它是自我宣稱、協定不驗證,client SHOULD NOT 拿它來改變行為,更不能拿來做安全決策。

能力必填,身分不必

版本現在住在 _metaio.modelcontextprotocol/protocolVersion。同一層還有兩個欄位,必填狀態跟直覺相反:

_meta key 型別 必填
io.modelcontextprotocol/protocolVersion string
io.modelcontextprotocol/clientCapabilities ClientCapabilities
io.modelcontextprotocol/clientInfo Implementation

「我支援什麼」是必填,「我是誰」不是。這個排序有它的道理:server MUST NOT rely on capabilities the client has not declared,你沒宣告的能力它不准用,真的缺了就得回 MissingRequiredClientCapabilityError-32021),data.requiredCapabilities 裡列出缺哪幾項。clientInfo 只給顯示、log 跟 debug 用,規格建議每個請求都帶,但沒把它列成必填。

漏掉必填欄位的後果也不歸版本管。規格說那叫 malformed,server MUST 回 -32602(Invalid params),HTTP 上狀態碼一律 400

走 HTTP 還得把同一個版本字串寫第二次。每個 POST MUST 帶 MCP-Protocol-Version 標頭,例如 MCP-Protocol-Version: 2026-07-28,而它的值 MUST 跟 body _meta 裡那一格相同,兩邊不一樣 server 必須回 400 加一個 HeaderMismatch 錯誤。同一個值寫兩遍聽起來很蠢,用途不在 server 身上。中間的 load balancer 跟 gateway 不必拆 body 就看得到你在講哪一版。

版本被拒絕的時候

對面不支援你要的版本(不管是沒聽過,還是聽過但選擇不支援),它 MUST 回這個:

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25"],
"requested": "1900-01-01"
}
}
}

client SHOULD 從 supported 挑一個雙方都有的版本重試,挑不到就把錯誤丟給使用者看。

這裡有個心智模型要換掉。重試的單位是這一個請求,不是這條連線。版本是請求的屬性,server 逐一決定收或不收,同一條連線上前一個請求過了,不代表下一個免驗。

選配功能不走版本這條路

版本號只管核心協定。MCP Apps、Tasks 這類選配的擴充走的是另一套機制:capabilities 底下的 extensions 欄位,一張「擴充識別碼對應該擴充設定物件」的 map。識別碼要照 _meta 的 key 命名規則走,前綴是強制的,官方頁面自己舉的兩個例子是 io.modelcontextprotocol/ui(MCP Apps)跟 io.modelcontextprotocol/tasks(Tasks)。設定物件寫成空的 {},意思是「我支援,沒有額外設定」。

拆開來放的好處是加一個擴充不必動版本號。代價是你多了一組要處理的分歧:一邊支援、另一邊不支援的時候,規格要求支援的那一方 MUST 二選一,退回核心協定的行為,或是用適當的錯誤直接拒絕。至於該退到哪、怎麼退,規格把球丟給各個擴充自己的文件去寫。

有一件事還是有狀態,而且要你自己記

舊做法像進門先辦一張通行證,證發下來之後你在裡面做的每件事都靠「我剛才辦過」。新做法是每次敲門都把證件掏出來。

省掉一次往返只是附帶效果。真正的改變是 server 不必記得你。規格把 stateless 寫得很硬:Servers MUST NOT rely on prior requests over the same connection to establish context。同一版把協定層的 session 一起拿掉之後,授權那一側也長出了配套的規定,站上談 audience 驗不過要回 401 那篇寫過:手上有一串 handle 什麼都不能證明。

但有一樣東西規格反而要 client 自己記著,就是對面屬於哪個時代。原文寫 The era determination is a property of the server, not of an individual request,client SHOULD 把結果快取起來,stdio 以 server process 的生命週期為界、HTTP 以 origin 為界,MAY 跨重啟持久化,等哪天這個假設失準了再重新探測一次。

判斷一次就好,不要每個請求都重新猜。

那些救不回來的舊 client

相容矩陣裡有一格是真的死的:Legacy client 對 Modern server。舊 client 手上只有 initialize,而規格明講 Legacy clients have no fall-forward mechanism,它不會往前跳,也沒有東西可以讓它往前跳。

規格能做的只剩一句建議,而且是寫給 server 作者看的:只支援新版的 server,SHOULD 在它回給 initialize 的任何錯誤裡,把自己支援的協定版本寫進去,不管走哪一個傳輸層。理由是那可能是舊 client 唯一能顯示給使用者看的診斷訊息。

這句值得記一下。它要求的動作發生在你這邊,受益的卻是一群你永遠收不到 bug report 的人。他們的 client 連你的錯誤結構都解不開,只能把訊息原樣印在畫面上。願意在錯誤字串裡多寫那幾個版本號,是給下游留一條線索。

那舊的那一套還要撐多久?我在 versioning 頁上沒找到答案。規格給的是另一個時鐘:被標成 Deprecated 的功能,至少要在規格裡留滿十二個月才有資格被移除,走 expedited-removal 例外的也至少九十天。但那個下限管的是「這一版底下被標記為 Deprecated 的功能」,例如 2024-11-05 那套 HTTP+SSE 傳輸層,它從 2025-03-26 就被標了,到現在還掛在規格裡。initialize 握手是舊版本協定的一部分,不是這一版裡的一個 Deprecated 功能,那個下限套不到它身上。所以「我的 dual-era 支援還要留多久」這題,規格頁上查不到答案,得去看你的使用者什麼時候把手上的 client 升完。

先講清楚這篇的邊界。整篇是讀規格寫出來的,我沒有實作過任何一端,也沒有真的送過一次 server/discover。各家 SDK 跟進到哪一版我沒查,別把「規格這樣寫」讀成「你裝的那包已經這樣做了」。

回到那個查不到的錯誤碼

現在你知道它為什麼查不到了。舊 client 撞上新 server 那一格,規格認定沒救,於是連錯誤碼都懶得定,只留下一條「拜託你把版本寫進訊息裡」的建議。

要站到另一邊倒是不難。你的 client 一開始就決定吃哪幾個時代,用 server/discover(stdio)或是讀 400 的 body(HTTP)判一次對面是誰,把結果快取在 server 這個層級,然後讓每個請求自己帶著版本上路,被 -32022 打回來就換一個版本再送一次。

背後就兩條線:版本是請求的屬性,時代是 server 的屬性。這兩件事分開放,剩下的都好講。