AI 互動教室 ‹ 學 LLM 應用開發
下載 .py 開啟實戰 notebook ↗ 留言回報
FASTMCP 4 · 補充 C · WHAT'S NEW, ON THE WIRE

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.0b1fastmcp-tasks==4.0.0b1,與第 3 課同版。

01 · 背景任務

投遞、輪詢、取結果——你只寫 task=True

from fastmcp.dependencies import Progress from fastmcp_tasks import TasksExtension mcp = FastMCP("慢工茶行") mcp.add_extension(TasksExtension()) @mcp.tool(task=True) # 工具必須是 async def async def brew(cups: int, progress: Progress = Progress()) -> str: await progress.set_total(cups) for i in range(cups): await progress.set_message(f"第 {i + 1} 杯") await asyncio.sleep(0.3) await progress.increment() return f"泡好 {cups} 杯"

長工作不能把 HTTP 請求開著等。4.0 用 MCP 的 tasks 擴充解決:伺服器先回一張任務單 (taskIdstatus: workingpollIntervalMs), 客戶端之後用 tasks/get 輪詢,完成時結果就在同一個回應裡。 FastMCP 的 Client 把這整件事藏起來——程式碼跟呼叫普通工具一模一樣,只有側錄表看得出差別。

notebook 還用裸 POST 自己輪詢一次:tools/call 的 params 多一個 "task": {"ttl": 60000} 就是「我要背景跑」的宣告;每次 tasks/getstatusMessage 就是 progress.set_message() 寫的「第 N 杯」。 舊協定客戶端(mode="legacy")呼叫同一個工具則直接同步跑到底——tasks 是新協定才協商得到的能力。

到 notebook 的 1️⃣ 節:兩種協定的側錄表、自己輪詢一次
02 · 回應快取提示

伺服器說「這份可以留 300 秒」,客戶端三次只打一次

mcp = FastMCP("天氣", cache_ttl=300, cache_scope="public") # 伺服器:附上提示 async with Client(url, cache=True) as c: # 客戶端:尊重提示 await c.list_tools() # 打伺服器 await c.list_tools() # 吃快取 await c.list_tools() # 吃快取

因為新協定的請求自帶一切、不綁連線,回應才快取得起來。裸 POST 一次 tools/list 可以看到結果裡多了 ttlMs: 300000cacheScope: "public"; 實測開快取的客戶端三次 list_tools() 伺服器只收到 1 次,沒開的收到 3 次。 "public" 表示回應不含個人資料,gateway 或一整群客戶端可以共用同一份。

到 notebook 的 2️⃣ 節:快取命中對照表、裸 POST 看提示欄位
03 · GATEWAY 路由 HEADER

參數升成 header,負載平衡器不拆 body 就能分流

@mcp.tool def forecast(city: Annotated[str, Field(json_schema_extra={"x-mcp-header": "City"})], days: int = 1) -> str: ... # 側錄到的請求: # POST tools/call forecast {'mcp-param-city': 'taipei'} # POST tools/call forecast {'mcp-param-city': '=?base64?5Y+w5YyX?='} ← 「台北」

第 3 課看過每個新協定請求都帶 Mcp-MethodMcp-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️⃣ 節:新舊協定側錄對照
04 · 自訂 EXTENSION

在協定上加自己的東西——背景任務就是這樣做出來的

class CallCounter(ServerExtension): identifier = "tw.agentclass/call-counter" # 反向 DNS 形式 def settings(self): return {"unit": "calls"} # 出現在 capabilities.extensions def methods(self): # 加自訂的 JSON-RPC 方法 return [MethodBinding(method="callCounter/get", params_type=GetParams, handler=self.get_count)] async def intercept_tool_call(self, params, context, call_next): # 每次 tools/call 的最後一道關卡 self.count += 1 return await call_next() mcp.add_extension(CallCounter())

客戶端連上時 capabilities.extensions 裡會看到 io.modelcontextprotocol/tasks(若有裝)、 內建的 io.modelcontextprotocol/ui,以及你自己的。自訂方法用裸 POST 帶 mcp-method: callCounter/get 就能呼叫。同一節還有 @mcp.completion: 一個 handler 回答所有 prompt/template 參數的自動完成請求,輸入 候選剩兩個、輸入 剩一個。

到 notebook 的 4️⃣–5️⃣ 節:completion 與 extension
05 · 安全與規模

路徑穿越進不了函式;五十個工具變兩個

請求 docs://{path*}結果handler 有被叫到?
guidea/b✅ 回內容
../etc/passwd%2e%2e/x🛑 Resource not found沒有
/etc/passwdx%00y🛑 Resource not found沒有

4.0 預設在 resource template 參數進 handler 之前就篩掉路徑穿越、絕對路徑與 null byte,對外看起來就像資源不存在。 合法但長得像穿越的值(git ref)用 ResourceSecurity(exempt_params={...}) 豁免。

規模的另一端:工具太多時 FastMCP(transforms=[BM25SearchTransform()])list_tools() 只剩 search_toolscall_tool 兩個, 模型先用自然語言搜(「delete something from the database」→ delete_record 排第一)再呼叫; 原本的工具只是從清單隱形,指名呼叫仍然可以。這個 transform 不是 4.0 新增,但跟大伺服器場景常一起出現。

到 notebook 的 6️⃣–7️⃣ 節:六個路徑實測、BM25 搜尋
06 · 總表

4.0 新功能一句話

功能一句話在哪
背景任務task=True;新協定投遞→輪詢,舊協定同步1️⃣
回應快取提示cache_ttlcache_scopeClient(cache=True)2️⃣
路由 headerx-mcp-headerMcp-Param-*3️⃣
參數自動完成@mcp.completion4️⃣
Extension 介面自訂 capability/方法/tools/call 攔截5️⃣
路徑安全template 參數預設擋 ..//、null byte6️⃣
多回合互動工具、移除 ctx.sample()回傳 InputRequiredResult,下一個請求接著做補充課「狀態」
Identity assertion、InsufficientScopeError、client credentials企業身分與精準補授權補充課「認證」
版本化工具、OpenAPI → MCP、middleware知道有就好notebook 8️⃣
07 · 實戰

換你動手

LEVEL 1

cache_ttl 改成 1 秒,開快取連列兩次、sleep(1.2) 後再列第三次——側錄表多出一個 tools/list

LEVEL 2

forecast 再加一個也標 x-mcp-headertenant 參數,側錄表同時出現兩個 mcp-param-*。記得先 list_tools()

LEVEL 3

寫一個在 intercept_tool_call 短路的 extension:沒宣告的客戶端照常,有宣告的(Client(extensions=[advertise(...)]))拿到加工過的結果。

卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。

08 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

你的 MCP 工具一跑要好幾分鐘(例如影片轉檔)。客戶端呼叫時 HTTP 請求一直開著等,常撞 timeout,使用者也看不到進度。最佳做法是?

這正是 4.0 背景任務要解的問題:伺服器先回任務單(taskIdstatus: workingpollIntervalMs),客戶端用 tasks/get 輪詢,progress.set_message() 寫的字就出現在 statusMessage 裡。A 只是把爆炸時間延後——中間任何一層(gateway、負載平衡器)都可能先斷線,而且還是沒進度;B 把協定該解的問題轉嫁成客戶端的流程負擔;D 做得到,但等於自己土砲 TasksExtension 已經給你的任務單、輪詢、進度與 mode="required"/"forbidden" 控制。

Q2 錯誤診斷

你給 forecastcity 參數標了 x-mcp-header,寫了支腳本連上就直接呼叫,結果被伺服器拒絕。最可能的原因是?

await c.call_tool("forecast", {"city": "taipei"}) MCPError: Mcp-Param-City header is missing but the request body's 'city' argument is present

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 顯示函式根本沒被執行。最可能的原因是?

read_resource("docs://HEAD~3..HEAD") 🛑 Resource not found (handler 完全沒被呼叫)

4.0 預設在 template 參數進 handler 之前就篩掉路徑穿越(../)、絕對路徑與 null byte,被擋的請求對外看起來就像資源不存在——「handler 完全沒被呼叫」正是這一層在動作的鐵證,A 因此可排除(伺服器根本還沒走到查資源那一步)。C 不通:實測 %2e%2e 一樣被擋;D 能解,但整台伺服器所有 template 參數都失去保護,../etc/passwd 也跟著放行——正確做法是用 exempt_params 只豁免確定安全的那一個參數。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1 分鐘)——免費 CPU 環境即可,不需要 GPU

不想用 molab?下載 fastmcp4-features_ext.py 後在自己電腦 uvx marimo edit --sandbox fastmcp4-features_ext.py,依賴會自動安裝。