FastMCP 4 專屬功能:
背景任務、快取、路由 header、擴充
第 3 課你看到 4.0 把協定變成無狀態。這堂補充課把 4.0 建在這個地基上的新功能一個一個拿出來用, 而且每一個都用第 3 課那台側錄器看線路上真的發生了什麼。先看最有感的一個: 同一行 call_tool("brew", cups=3),新協定變成「投遞 → 輪詢 → 取結果」,舊協定一發等到底——
新協定(4.0 預設):背景任務
舊協定(mode="legacy"):同步等到底
上面的請求序列是 notebook 實測的側錄紀錄(每杯 0.3 秒;tasks/get 次數每次跑會飄)。 這堂課的 notebook 不連任何外部服務,全部在 molab 裡起本機伺服器。本課用 fastmcp==4.0.0b1 + fastmcp-tasks==4.0.0b1,與第 3 課同版。
投遞、輪詢、取結果——你只寫 task=True
長工作不能把 HTTP 請求開著等。4.0 用 MCP 的 tasks 擴充解決:伺服器先回一張任務單 (taskId、status: working、pollIntervalMs), 客戶端之後用 tasks/get 輪詢,完成時結果就在同一個回應裡。 FastMCP 的 Client 把這整件事藏起來——程式碼跟呼叫普通工具一模一樣,只有側錄表看得出差別。
notebook 還用裸 POST 自己輪詢一次:tools/call 的 params 多一個 "task": {"ttl": 60000} 就是「我要背景跑」的宣告;每次 tasks/get 的 statusMessage 就是 progress.set_message() 寫的「第 N 杯」。 舊協定客戶端(mode="legacy")呼叫同一個工具則直接同步跑到底——tasks 是新協定才協商得到的能力。
到 notebook 的 1️⃣ 節:兩種協定的側錄表、自己輪詢一次伺服器說「這份可以留 300 秒」,客戶端三次只打一次
因為新協定的請求自帶一切、不綁連線,回應才快取得起來。裸 POST 一次 tools/list 可以看到結果裡多了 ttlMs: 300000 與 cacheScope: "public"; 實測開快取的客戶端三次 list_tools() 伺服器只收到 1 次,沒開的收到 3 次。 "public" 表示回應不含個人資料,gateway 或一整群客戶端可以共用同一份。
到 notebook 的 2️⃣ 節:快取命中對照表、裸 POST 看提示欄位參數升成 header,負載平衡器不拆 body 就能分流
第 3 課看過每個新協定請求都帶 Mcp-Method/Mcp-Name;4.0 再讓參數也能升成 Mcp-Param-* header,例如依租戶或城市把請求釘到專屬後端。兩個實測細節: 客戶端要先 list_tools() 看過 schema 才知道哪些參數要升 header——沒看過就直接呼叫, 伺服器會拒絕 Mcp-Param-City header is missing but the request body's 'city' argument is present (header 只是路由提示,body 才是真相,兩者必須一致);非 ASCII 的值會被包成 =?base64?…?=。 舊協定完全沒有這些 header。
到 notebook 的 3️⃣ 節:新舊協定側錄對照在協定上加自己的東西——背景任務就是這樣做出來的
客戶端連上時 capabilities.extensions 裡會看到 io.modelcontextprotocol/tasks(若有裝)、 內建的 io.modelcontextprotocol/ui,以及你自己的。自訂方法用裸 POST 帶 mcp-method: callCounter/get 就能呼叫。同一節還有 @mcp.completion: 一個 handler 回答所有 prompt/template 參數的自動完成請求,輸入 貓 候選剩兩個、輸入 颱 剩一個。
到 notebook 的 4️⃣–5️⃣ 節:completion 與 extension路徑穿越進不了函式;五十個工具變兩個
| 請求 docs://{path*} | 結果 | handler 有被叫到? |
|---|---|---|
| guide、a/b | ✅ 回內容 | 有 |
| ../etc/passwd、%2e%2e/x | 🛑 Resource not found | 沒有 |
| /etc/passwd、x%00y | 🛑 Resource not found | 沒有 |
4.0 預設在 resource template 參數進 handler 之前就篩掉路徑穿越、絕對路徑與 null byte,對外看起來就像資源不存在。 合法但長得像穿越的值(git ref)用 ResourceSecurity(exempt_params={...}) 豁免。
規模的另一端:工具太多時 FastMCP(transforms=[BM25SearchTransform()]) 讓 list_tools() 只剩 search_tools 與 call_tool 兩個, 模型先用自然語言搜(「delete something from the database」→ delete_record 排第一)再呼叫; 原本的工具只是從清單隱形,指名呼叫仍然可以。這個 transform 不是 4.0 新增,但跟大伺服器場景常一起出現。
到 notebook 的 6️⃣–7️⃣ 節:六個路徑實測、BM25 搜尋4.0 新功能一句話
| 功能 | 一句話 | 在哪 |
|---|---|---|
| 背景任務 | task=True;新協定投遞→輪詢,舊協定同步 | 1️⃣ |
| 回應快取提示 | cache_ttl/cache_scope + Client(cache=True) | 2️⃣ |
| 路由 header | x-mcp-header → Mcp-Param-* | 3️⃣ |
| 參數自動完成 | @mcp.completion | 4️⃣ |
| Extension 介面 | 自訂 capability/方法/tools/call 攔截 | 5️⃣ |
| 路徑安全 | template 參數預設擋 ../、/、null byte | 6️⃣ |
| 多回合互動工具、移除 ctx.sample() | 回傳 InputRequiredResult,下一個請求接著做 | 補充課「狀態」 |
| Identity assertion、InsufficientScopeError、client credentials | 企業身分與精準補授權 | 補充課「認證」 |
| 版本化工具、OpenAPI → MCP、middleware | 知道有就好 | notebook 8️⃣ |
換你動手
把 cache_ttl 改成 1 秒,開快取連列兩次、sleep(1.2) 後再列第三次——側錄表多出一個 tools/list。
給 forecast 再加一個也標 x-mcp-header 的 tenant 參數,側錄表同時出現兩個 mcp-param-*。記得先 list_tools()。
寫一個在 intercept_tool_call 短路的 extension:沒宣告的客戶端照常,有宣告的(Client(extensions=[advertise(...)]))拿到加工過的結果。
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
你的 MCP 工具一跑要好幾分鐘(例如影片轉檔)。客戶端呼叫時 HTTP 請求一直開著等,常撞 timeout,使用者也看不到進度。最佳做法是?
這正是 4.0 背景任務要解的問題:伺服器先回任務單(taskId、status: working、pollIntervalMs),客戶端用 tasks/get 輪詢,progress.set_message() 寫的字就出現在 statusMessage 裡。A 只是把爆炸時間延後——中間任何一層(gateway、負載平衡器)都可能先斷線,而且還是沒進度;B 把協定該解的問題轉嫁成客戶端的流程負擔;D 做得到,但等於自己土砲 TasksExtension 已經給你的任務單、輪詢、進度與 mode="required"/"forbidden" 控制。
Q2 錯誤診斷
你給 forecast 的 city 參數標了 x-mcp-header,寫了支腳本連上就直接呼叫,結果被伺服器拒絕。最可能的原因是?
header 只是路由提示,body 才是真相,伺服器會嚴格核對兩者一致——而客戶端要先看過 schema(list_tools())才知道哪些參數要升成 header,沒看過就直接呼叫,header 就缺了。B 無關:HTTP header 名不分大小寫;C 搞反了——只有非 ASCII 值(例如「台北」)才需要編碼,而且是客戶端自動包的;D 能讓錯誤消失,但代價是整個退回舊協定——舊協定完全沒有 Mcp-Param-* header,gateway 分流這個功能也就沒了。
Q3 情境題
你維護一台公開的天氣 MCP 伺服器,工具清單幾乎不變,但成千上萬個客戶端(前面還有一層 gateway)不停打 tools/list。要怎麼降低這些重複請求?
實測開 cache=True 的客戶端三次 list_tools() 伺服器只收到 1 次;"public" 宣告回應不含個人資料,gateway 與一整群客戶端可以共用同一份快取——正是「成千上萬個客戶端」場景要的。C 只差一個字,但 "private" 表示各自留、gateway 不能代為共用,效果差一大截;A 能動,但 schema 一改全體壞掉,快取提示就是為了取代這種硬寫死;B 方向相反——正因為新協定請求自帶一切、不綁連線,回應才快取得起來。
Q4 錯誤診斷
你的文件伺服器用 docs://{ref*} 模板收 git ref。使用者請求合法的 HEAD~3..HEAD 卻拿到下面的結果,而你在 handler 裡記的 log 顯示函式根本沒被執行。最可能的原因是?
4.0 預設在 template 參數進 handler 之前就篩掉路徑穿越(../)、絕對路徑與 null byte,被擋的請求對外看起來就像資源不存在——「handler 完全沒被呼叫」正是這一層在動作的鐵證,A 因此可排除(伺服器根本還沒走到查資源那一步)。C 不通:實測 %2e%2e 一樣被擋;D 能解,但整台伺服器所有 template 參數都失去保護,../etc/passwd 也跟著放行——正確做法是用 exempt_params 只豁免確定安全的那一個參數。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1 分鐘)——免費 CPU 環境即可,不需要 GPU
不想用 molab?下載 fastmcp4-features_ext.py 後在自己電腦
uvx marimo edit --sandbox fastmcp4-features_ext.py,依賴會自動安裝。