昨天那組工具全部是同步的,送出請求、收到 API 的回應、回傳給模型。控制實體設備時這個形狀不夠用,指令送出到感測值反映出來之間有一段時間,回一句「成功」講的只是 API 收到了。今天把這段差距補上,做法照規範走,並量出規範與手上的 SDK 之間差了什麼。
一次冷氣調溫實際經過四段,前三段在幾百毫秒內完成,第四段要幾秒到幾十秒:
同步工具在第二段就回傳了。它能報的只有「請求送達」,而使用者問的是「溫度設好了沒」。兩者之間可能出現的落差有三種:
三種情況在同步回應裡長得一模一樣。 要分得出來,回傳裡得有一個位置放「目前進行到哪」。
長時任務原本是核心協定裡的實驗功能,2026-07-28 這一版移進官方 extension io.modelcontextprotocol/tasks。changelog 列的改動有四項:
| 改動 | 內容 |
|---|---|
| 取得結果的方式 | 阻塞式的 tasks/result 換成輪詢式的 tasks/get |
| 新增方法 | tasks/update,讓 client 在任務執行中把輸入送進去 |
| 移除方法 | tasks/list |
| 觸發方式 | server 可以主動回傳 task handle,不需要逐個請求先徵求同意 |
同一版另外在 ClientCapabilities 與 ServerCapabilities 加了 extensions 欄位,核心以外的功能從此有正式的宣告位置。
裝起來的是 MCP Python SDK 2.2.0 與 FastMCP 4.0.10。SDK 的 LATEST_PROTOCOL_VERSION 已經寫 2026-07-28,型別那一側對得上的程度是:
| 規範這一版 | SDK 2.2.0 的型別 | 對得上嗎 |
|---|---|---|
tasks/get 輪詢 |
GetTaskRequest,method 是 tasks/get |
有 |
tasks/result 已被取代 |
GetTaskPayloadRequest,method 仍是 tasks/result |
還在 |
tasks/list 已移除 |
ListTasksRequest,method 是 tasks/list |
還在 |
新增 tasks/update |
整個套件搜不到這個字串 | 沒有 |
TaskStatus 的列舉是五個值,working、input_required、completed、failed、cancelled。Task 這個型別帶七個欄位,task_id、status、status_message、created_at、last_updated_at、ttl、poll_interval。
規範描述的形狀與 SDK 提供的形狀差一個世代。 照 extension 的介面寫下去,tasks/update 那條路現在沒有對應的型別可以用。
同一份 changelog 的第一條處理的就是這個問題。協定層的 session 與 Mcp-Session-Id 標頭都被移除之後,需要跨呼叫狀態的 server 走的是另一條路:由 server 鑄造明確的 handle,當成一般的工具參數傳遞。
這條路不需要等 extension 的型別補齊,用的全是既有的工具機制。照它實作出來的形狀是三個工具:
| 工具 | 做什麼 |
|---|---|
set_ac_temp_async |
判定通過後送出指令,立刻回傳 handle 與初始狀態 |
get_control_task |
拿 handle 查目前狀態 |
cancel_control_task |
停止追蹤一個尚未確認的任務 |
狀態值沿用 TaskStatus 那五個字面值,欄位沿用 Task 那七個。介面走工具,語彙跟著規範走,等 SDK 補上 extension 的型別之後,要換的是介面而非狀態機。
背景那一段做的是送出指令後反覆回讀,讀到預期的值就把狀態推到 completed,次數用盡則推到 failed 並在訊息裡寫明送出成功但沒看到預期狀態。
判定完成與否靠的是回讀,因此只有感測端點會回報的量做得成非同步工具。確認的方式是直接打那支端點,把回傳的欄位攤開:
| 量 | 感測端點的欄位 | 做得成非同步工具 |
|---|---|---|
| 冷氣開關與設定溫度 | Status、Temperature |
做得成 |
| 各房間的溫濕度與空氣品質 | 四項讀值 | 本來就是讀取,不需要 |
| 風扇吹向 | 每台設備兩個欄位,值是 IN 或 OUT |
做得成 |
安全判定那個模組的 docstring 寫的是感測端點不回目前的吹向,因而無法偵測方向的切換。實際打端點拿回來的內容裡,兩台設備各帶兩個吹向欄位,值就是控制端點吃的那組列舉。註解的說法與端點的實際回傳對不上,判定依據取端點。
這件事往下帶出兩個結果:
回讀比對還有一個細節要處理。控制端點吃的是 ON 與 OFF,感測端點回的是 On,直接拿字串相等去比會一路判定成未完成,比對前得先正規化。
一個量讀不讀得回來,去打那支端點確認,程式碼註解不作為依據
以替身 API 執行,那支 API 設定成指令受理後 4 秒才讓讀值改變。呼叫端每秒查一次:
| 經過秒數 | status | status_message |
|---|---|---|
| 0.0 | working |
已受理,尚未確認實際狀態 |
| 1.0 | working |
已受理,尚未確認實際狀態 |
| 2.0 | working |
已受理,尚未確認實際狀態 |
| 3.1 | working |
已受理,尚未確認實際狀態 |
| 4.1 | working |
已受理,尚未確認實際狀態 |
| 5.1 | completed |
第 5 次回讀確認到預期狀態 |
任務自己記的兩個時間戳相差 5.05 秒,第五次回讀命中。背景迴圈設定的間隔是 1 秒,每輪多出來的時間來自一次 HTTP 往返。
完成時的 result 欄位帶兩份資料,sent 是 API 對控制指令的回應,observed 是最後一次回讀到的值,兩者都是 26。判定依據是回讀到的那個值,不是 API 的回應。
確認閘門在送出之前就判定,非同步這條路繞不過去:
| 呼叫 | 回傳 |
|---|---|
set_ac_temp_async(26) |
判定通過,回 handle |
set_ac_temp_async(20) |
needs_confirmation: true,沒有建立任務 |
set_ac_temp_async(35) |
error: true,沒有建立任務 |
另外兩條路徑:
cancelled,六秒後再查仍是 cancelled,背景迴圈看到狀態已經改掉就停止推進ok: false 與一行說明,查無此 task_id
取消的範圍限於追蹤這一段。它停的是追蹤,已經送到設備的那道指令不會被收回,狀態變成 cancelled 表示的是呼叫端不再等這個結果。
第一項是 schema。三個工具加起來 1,608 byte:
| 工具 | schema byte |
|---|---|
set_ac_temp_async |
679 |
get_control_task |
505 |
cancel_control_task |
424 |
原本五個同步工具是 3,474 byte,加上這三個是 5,082,增加 46%。調溫這件事現在有兩種做法,模型每一輪都看得到兩套。
第二項是 handle 的壽命。任務表是行程裡的一個字典,ttl 預設 300 秒,超過就把狀態推成 failed。這帶來兩個限制:
task_id 查不到對應的任務協定層拿掉 session 之後,狀態沒有消失,它移到實作裡由自己管。 要跨重啟就得寫進儲存體,要跨副本就得共用一份。
無狀態講的是協定這一層,系統的狀態移到實作裡由自己管
寫之前先去翻 SDK,想照 extension 的介面實作,翻到一半發現 tasks/update 整個套件裡搜不到。那一刻要決定的是等它補上,還是換一條路。
換路的理由來自同一份 changelog。移除 session 那一條後面接著寫了替代做法,由 server 鑄造 handle、當成一般工具參數傳遞。這句話原本讀起來像是給「需要跨呼叫狀態」的 server 的一般性建議,實際上它就是長時任務要的那個東西。
規範文件裡寫得最有用的段落,常常是移除某個東西時附帶的那一句替代做法。刪掉 session 那一條佔的篇幅只有兩行,它決定的事情比整個 tasks extension 還多。
一項功能還沒有實作可用時,規範多半已經把它的職責分給了別的地方
改接一個別人寫的 MCP server,OAuth 的 client 這次不是 agent 自己,組態裡因此少掉兩個區塊。