把「server 主動問使用者」這個能力拿掉,MCP server 反而變好寫了。

這句話在 2026-07-28 這版規格上是字面成立的。MRTR 那頁開場的 Note 寫得毫不客氣:server 要送 roots/listsampling/createMessageelicitation/create 這類請求,MUST 走新的模式,然後補了一句 The previous pattern of server-initiated requests is no longer supported. This is a breaking change.

八月那篇寫 A2A 的時候,我卡在 MCP 沒有「我需要你補一點東西才能繼續」這個狀態。那篇當時的結論是:你硬要在 MCP 工具裡表達它,只能自己發明一套約定,回一包特殊的 JSON 說「請再呼叫我一次並帶上 X」,再去 client 那邊寫膠水認這個約定。

那包特殊的 JSON 現在寫進規格了。

那包 JSON 現在有名字了

它叫 InputRequiredResult,是一種 Result,不是錯誤。裡面兩個欄位都是選配的:inputRequests 是一張 map,key 由 server 自己取名、value 是它要 client 幫忙跑的請求;requestState 是一個「只對 server 自己有意義」的不透明字串。規格對 client 的用字很硬:Clients MUST NOT inspect, parse, modify, or make any assumptions about its contents.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"github_login": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please provide your GitHub username",
"requestedSchema": { "type": "object", "properties": { "name": { "type": "string" } } }
}
}
},
"requestState": "AEAD-protected blob"
}
}

inputRequests 的 value 只能是三種請求物件之一:ElicitRequestCreateMessageRequestListRootsRequest。兩個欄位都可以不填,但規格要求至少要有一個,Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response. 只給 requestState 不給 inputRequests 也合法,那代表「我不用你去問誰,你直接拿著這張條子再打一次就好」。

client 收到之後,蒐集完使用者的回覆,把結果放進 inputResponses 再送一次原本那個請求。inputResponses 的 key 跟 inputRequests 一一對應,所以 server 不必猜哪一包對應哪一問。

跟自己發明那一套,差在哪

八月那套自訂約定跟 InputRequiredResult 擺在一起,形狀幾乎重疊:server 回一包 JSON 說「我還缺東西,帶上再來一次」,client 補完再送一次。差別不在形狀,在沒對上的時候誰倒楣。

自訂約定的代價分兩層。一層是重複工:約定是你發明的,接一個 client 就得在那邊寫一次膠水,換一個 server 又得重寫一次。另一層更難處理:你回一包 JSON 要 client 去問使用者,可是它到底有沒有能力問,你不知道,只能送出去之後看它怎麼回。

規格化之後這兩層都換掉了。膠水那層變成 client 本來就要實作的東西,不是你跟它私下講好的;能力那層變成一條硬規定,server MUST NOT 送出 client 沒宣告支援的請求型別,對方答不出來這件事從執行期意外變成規格違反。

換掉之後多出來的是另一件事:自訂約定的那包 JSON 通常留在你自己家裡,requestState 卻要經過使用者的手。

「完全獨立」四個字才是支點

整份設計的重量壓在一句話上:

Note that the requests in each step are completely independent: the server processing the retry does not need any information beyond what is directly present in the retry request.

處理重送的那台 server,不需要任何「當時那台 server 才知道」的東西。規格另外還規定,重送必須換一個 JSON-RPC id,因為兩次是各自獨立的請求;而第一次那個請求在 server 回出 InputRequiredResult 的當下就結束了,規格的時序圖上直接標著 Initial Request Terminated

換個場景就懂了。以前是櫃檯店員拿著你的單子站在那裡等你回話,你不回,他的手就一直被佔住;現在他把單子連同一張蓋了章的便條還給你,說補好這格再來一次。櫃檯空出來,下一次誰來接手都可以。

這件事讓你少寫的東西,比多寫的多。

server 端那個「誰在等誰」的表不用了。MRTR 開宗明義說的設計目標就是 without requiring a shared storage layer across server instances or requiring stateful load balancing,沒有共享儲存層,也不需要有黏性的負載平衡。掛在那裡等回覆的執行續不用了。等太久要不要放棄、使用者把視窗關掉怎麼辦、連線斷了那個 pending request 該不該回收,這些分支一起消失,因為規格明講 Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request. 對方不回來是預設情況,不是例外路徑。你的 tool handler 退化成一個吃參數、吐結果的函式,可以重跑、可以放在任何一台機器上。

順著同一個方向,transport 那層也瘦了一圈。2026-07-28 版 Streamable HTTP 頁面的 Info 方塊只列了兩條改動:移除 GET 串流端點、移除協定層 session。但這兩條往下長出來的動作不只兩個。舊版 client 來敲的話,GET 或 DELETE 回 405 Method Not AllowedMcp-Session-Id 收到就忽略、不簽發也不回送,Last-Event-ID 同樣忽略,規格原句是 Resumable SSE streams via Last-Event-ID are not supported. 取消的語意也跟著換了:在 HTTP 上,關掉那條 SSE 回應串流本身就是取消訊號,notifications/cancelled 現在只用在 stdio。至於 server 想主動推「工具清單變了」這種通知,改由 client 送一個 subscriptions/listen 請求,那個請求的回應串流本身就是長命的通知管道。

這一串對 server 來說跟 MRTR 是同一件事:協定層 session 沒了,那張「誰是誰」的表就沒有存在的理由,跟前面拿掉的 pending request 表是同一張桌子清乾淨。對 client 就不是了,它多了活。原本靠 GET 開一條長連線收通知的,得改送 subscriptions/listen;原本斷線靠 Last-Event-ID 續接的,得自己決定重連之後怎麼補。少寫程式碼這件事在 server 那邊成立,走到 client 那邊是反過來的。

最省事的接法,會把規格剛拆掉的東西裝回去

requestState 是不透明字串,server 想放什麼都行。於是最省事的做法幾乎是自動浮現的:在 server 存一張 map 記住這次做到哪,requestState 只放那張 map 的 key。

規格沒有禁止你這樣做。但這樣做等於把它剛剛幫你拆掉的東西原封裝回去。那張 map 要跨實例共享,你就回到共享儲存層;不共享,你就得讓重送落回同一台,那是有黏性的負載平衡。兩條路都是 MRTR 的設計動機裡明文要避開的。而且你還多接了一整套自己維護的東西:過期清理、容量上限、重啟之後那些懸空的 key 怎麼辦。

正解是反過來:把要記的東西編碼進 requestState 本身。規格給的例子是 base64 過的 JSON、加密的 JWT、序列化的二進位,形式不限,重點是它得自己就是完整的,不是一把指向別處的鑰匙。

我沒有實作過任何一端,上面這段是從規格的設計動機推出來的判斷,不是我撞過的牆。

狀態沒有消失,只是換人保管

你可能已經聞到問題了。那張條子經過使用者的手,而使用者可以改它。

規格把這件事寫得很白:servers MUST treat requestState as an attacker-controlled input. 只要它會影響授權、資源存取或商業邏輯,server 就 MUST 保護它的完整性(規格點名 HMAC 或 AEAD),而且 MUST 拒絕驗不過的 state。唯一能省掉完整性保護的情況,是竄改它最壞也只會讓這次請求失敗。

再往下一層是防重放,規格用 SHOULD,列了三樣要塞進受保護的 payload 裡並且逐一驗:

  • 通過認證的主體,換一個人拿來用就拒絕
  • 一個短的有效期,過期就拒絕
  • 原始請求的識別,例如方法名加上關鍵參數的摘要,對不上就拒絕

然後它自己補了一個 Warning,這句才是真正值得記住的地方:這些措施只能把重放的窗口壓小、擋掉跨使用者與跨請求的重用,but do not by themselves guarantee single-use。一次性的兌換這種東西,server 還是得自己在後端保證只能用一次。

繞了一圈,最後那一哩還是要有狀態。差別在於它從「每個進行中的請求都要有一份」,縮成「只有真正需要一次性語意的操作才要有一份」。

「真正需要」的範圍比直覺窄。查一筆資料、讀一份檔案、重算一次結果,這些跑兩次不會有人受傷,全部不必管。要擋的是做第二次就出事的那種:兌換一張券、扣一次款、把一封通知送出去。這類操作在你的系統裡本來就該有一張防重複的表,MRTR 沒有多給你一張,它只是把需要這張表的範圍從「所有進行中的請求」縮到這一小撮。

縮完之後剩下的問題是那張表該活多久。規格給的防重放建議裡有一個短有效期,你那張表如果比它先過期,條子還在有效期內就能再用一次:上面那三條驗全部通過,重複扣款照樣發生。規格把 single-use 丟回你手上的時候,沒有一起丟回來的就是這個期限該怎麼配。

這個差距就是這次改版真正買到的東西。

規格只留了三個位置,而且填不全不算錯

InputRequiredResult 不是每個回應都能用。規格列了一張表,只有三種 client 請求可以拿它當回應,其餘一律 MUST NOT

Client Request Supports InputRequiredResult
prompts/get Yes
resources/read Yes
tools/call Yes

另一條限制是能力面的:server MUST NOT 送出 client 沒宣告支援的請求型別。client 沒宣告 elicitation,你的 inputRequests 裡就不准出現 elicitation/create。這條把「server 問了一個 client 根本答不出來的問題」從執行期意外變成規格違反。

錯誤處理那節有個很順的設計。如果 client 回來的資料少了 server 需要的那一項,規格要你再問一次,別回錯誤:respond with a new InputRequiredResult requesting the missing information again, rather than returning an error. 多出來看不懂的欄位也一樣,server SHOULD 直接忽略。server 甚至可以對同一個請求連續回好幾次 InputRequiredResult,一直問到資料齊為止。

填不全從「錯誤」降級成「迴圈的一次迭代」,這在 API 設計裡不常見,但它跟整個 MRTR 是同一個語氣:這條路本來就假設要走好幾趟。

什麼情況下這整套對你沒有好處

我的立場是:只要你的 MCP server 是對外的 HTTP 服務、而且不只跑一個實例,MRTR 帶來的淨值是正的,你會少寫的分支比多寫的樣板多。

會讓我改口的條件也很清楚。如果你的 server 走 stdio、單一 process、單一使用者,那上面講的好處你一項都拿不到,沒有負載平衡要煩、也不需要跨實例共享;你唯一多出來的是重送的樣板碼,還有把上下文塞進 requestState 再解出來的那段。那種情況下這次改版對你只是成本,不是收益。

這篇從頭到尾是讀規格寫出來的。我沒有實作過 MRTR 的任何一端,沒有送過一次 InputRequiredResult,也沒有查任何一家 SDK 跟進到哪一版,所以別把「規格這樣寫」讀成「你的 SDK 已經這樣做」。文中的規格描述以 2026-07-28 這版為準,規格會滾動改版。

回到那張蓋了章的便條。這次改版真正換掉的不是 API 形狀,是「誰有義務保管狀態」這個問題的答案。舊模型裡 server 保管,代價是它得一直醒著、得認得出同一個人、得有地方放;新模型裡 client 保管,server 只負責封印跟驗印,代價集中成一件事,就是那個封印要做對。

好處分散在每個地方,代價集中在一個地方。這種交換通常是划算的,前提是你真的把集中的那一點做好。

下次讀自己的程式碼時可以順手問一句:這裡面有哪些狀態,只是因為「對方還沒回答」而存在?那些通常就是可以交出去的東西。


參考來源