FastMCP 4 狀態:
無狀態協定上的三種記憶
第 3 課你用 SessionId 在無狀態協定上做了購物車。這堂課把「無狀態傳輸怎麼有狀態」講完整, 並回答一個所有人都會問的問題:狀態在客戶端與伺服器之間傳來傳去,它是加密的嗎?客戶端能不能偷看、能不能改? 下面是 notebook 從線路上截下來的一個真的 requestState——伺服器發給客戶端、要它下一回合帶回來的東西。 選一個攻擊方式,看伺服器怎麼回:
上面每一個回應都是 notebook 實測的紀錄(本機起多台 FastMCP 伺服器、用裸 httpx.post 一回合一回合發)。 本課不連任何外部服務,用 fastmcp==4.0.0b1,與第 3 課相同。
先分清楚:它活在哪、活多久、跨副本要什麼
請求內狀態
ctx.set_state/get_state
活在伺服器記憶體、一個請求就沒了:middleware 算好的東西給工具用。
跨副本:不需要。
Session 狀態
SessionId/UserSession
資料在伺服器端的 store,客戶端只拿鑰匙;活到 end_session 或 store 的 TTL。
跨副本:共用 store。
請求狀態
InputRequiredResult.request_state
真的經過客戶端:伺服器發、客戶端下一回合帶回;預設 10 分鐘。
跨副本:共用金鑰。
前兩種根本不離開伺服器,「要不要加密」的問題不存在——notebook 先用 tick 連叫三次證明請求內狀態每次從 0 開始, 再用同一個 MemoryStore 餵兩台 FastMCP 模擬兩個副本:副本 A 建的購物車、副本 B 讀得到,不共用 store 的副本 C 回 Invalid or unknown session。要記得它的安全模型:沒有認證時 session id 是一張不記名票——猜不到,但不等於隔離; 多租戶隔離要靠認證(補充課 A)。
到 notebook 的 1️⃣–2️⃣ 節:tick 三次、兩台副本共用 store工具「回傳一個問題」,客戶端答完再來一次
第三種記憶來自 4.0 的新招。無狀態協定沒有活連線讓工具中途 ctx.elicit() 問使用者,所以改成 guard 模式: 工具發現還沒拿到答案就回傳一個 InputRequiredResult,請求結束;客戶端問完使用者, 重新呼叫同一個工具把答案放在 inputResponses 帶上;工具從頭再跑一次,這次 ctx.input_responses 有值了。
對呼叫端 call_tool("book_table") 看起來就是一次呼叫、直接拿到「已訂位:4 位,8/30」—— SDK 的 elicitation_handler 自動跑完三回合(實測客戶端被問了 2 次)。 「第一回合問到的人數」靠 request_state 帶到第三回合,它真的經過客戶端。
到 notebook 的 3️⃣ 節:三回合訂位、SDK 自動跑完把 requestState 截下來——它是密文
notebook 在本機起一台真的 HTTP 伺服器,不用 SDK、用裸 httpx.post 一回合一回合發 tools/call, 把伺服器回來的 requestState 原封不動印出來:v1. 前綴+約 270–290 個 base64url 字元。 工具放進去的明文是 {"people": "4", "vip": true};解開 base64 之後找不到 people 也找不到 vip—— 它不是包起來的 JSON,是 AES-256-GCM 密文:
| 位置 | 內容 | 用途 |
|---|---|---|
| v1. | 格式版本 | 綁進 GCM 的 associated data,不能把 token 改頭換面當別的版本用 |
| 前 4 bytes | 金鑰指紋 | 金鑰環上 O(1) 找到該用哪一把(非祕密) |
| 接著 12 bytes | nonce | 每次加密重抽,同樣明文密文也不同 |
| 其餘 | 密文+驗證標籤 | 明文是一個 claims 信封:iat/exp(預設 10 分鐘)、綁定的 method、工具名、參數摘要、aud(伺服器名)、有認證時的使用者指紋,以及你的 s |
金鑰由伺服器祕密經 HKDF-SHA256 派生;你在工具裡寫 request_state=... 時完全沒碰密碼學——是 RequestStateBoundary 中介層在線路邊界自動封裝、自動驗證。
到 notebook 的 4️⃣ 節:裸 POST 三回合、把 token 拆開竄改、換伺服器、換參數、換伺服器名、等它過期
| # | 實驗 | 在測什麼 | 實測回應 |
|---|---|---|---|
| 1 | 把密文改掉一個字元 | 完整性(GCM 驗證標籤) | -32602 Invalid or expired requestState 五個全部同一句;真正原因只進伺服器 log:seal/unknown key/request binding/audience/expired |
| 2 | 同一個 token 打另一台(各自的臨時金鑰) | 金鑰不同就解不開 | |
| 3 | 同一個 token,arguments 改掉 | 綁定:只對同一個工具+同樣參數有效 | |
| 4 | 同一把金鑰、伺服器名不同 | aud:共用金鑰的兩個服務也不能互換 | |
| 5 | ttl=2,等 2.5 秒 | 過期 |
伺服器故意不說是哪裡錯——不給攻擊者可以逐步修正的線索。回到開頭的問題:傳來傳去的狀態是加密的。 客戶端讀不到、改不了、換不了用途、放不久。
到 notebook 的 5️⃣ 節:五個實驗一次跑共用金鑰與輪替
實驗 2 揭露了部署陷阱:預設金鑰是每個 process 啟動時隨機產生的。多回合工具的第 2 回合落到另一台副本、或伺服器重啟, 使用者就莫名其妙拿到 Invalid or expired requestState。解法:所有副本同一把金鑰(≥ 32 bytes,放環境變數)。
notebook 證明:共用金鑰的副本 C 發的 token,到副本 D 照樣完成訂位。三種記憶跨副本收斂成一句: 請求內狀態不用、session 狀態共用 store、請求狀態共用金鑰。
到 notebook 的 6️⃣–7️⃣ 節:跨副本、金鑰環、該用哪一種記憶換你動手
把 ttl=2 改成 60 再等 2.5 秒——這次應該成功;再把副本 C 接上 shared_store。
給 book_table 加第三回合「要不要靠窗?」,人數與日期都放進 request_state;用裸 POST 看每回合的 token 都不一樣(nonce 重抽)。
做金鑰輪替:一台 keys=[新, 舊]、一台 keys=[舊],證明舊發新收 ✅、新發舊收 🛑,並說出三階段為什麼不能跳。
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
你在做點餐 MCP 服務:使用者的購物車要在幾十次工具呼叫之間一直都在,服務要跑 10 個副本。狀態該放哪?
「跨很多次呼叫、可能變大」的狀態正是 session 狀態的主場:資料留在伺服器端、客戶端只拿鑰匙,跨副本只要共用 store。A 的請求內狀態活一個請求就沒了——tick 連叫三次每次歸零就是證明;B 能動但副作用大:request_state 每回合都經過客戶端來回、預設 10 分鐘過期,塞大東西正是課裡點名的誤用;D 是土砲——副本一重啟購物車就消失,還把負載綁死在單台。
Q2 錯誤診斷
你想拿 ctx.set_state 當計數器,把 tick 連叫三次,結果如下。為什麼不是 1、2、3?
ctx.set_state/get_state 是給同一個請求內的 middleware 與工具互傳值用的,每個請求從零開始正是無狀態協定的本色。B、C 講的是另一種記憶:SessionProvider 與 store 服務的是 SessionId 那條路,掛了也不會讓 ctx.set_state 跨請求。D 方向錯——三次呼叫是循序 await,沒有並發。真的要計數就開 session,把 n 存進 store。
Q3 錯誤診斷
多回合訂位工具在本機測試一切正常;上線跑兩個副本後,使用者常常訂到一半就收到這個錯。最可能的原因是?
這是課裡點名的部署陷阱:預設金鑰每個 process 啟動時隨機產生,單機測不出來、一上多副本就中——攻擊實驗 2「拿去另一台伺服器」正是同一件事。修法:RequestStateSecurity(keys=[os.environ["REQUEST_STATE_KEY"]]),所有副本同一把(伺服器重啟也不再作廢進行中的對話)。A 症狀相似,但預設 ttl 有 600 秒,而且過期解釋不了「單機正常、多副本才出事」;C 走的是 session 那條路,錯誤會是 Invalid or unknown session;D 是 SDK 防無限迴圈的上限,兩三回合的訂位差得遠。
Q4 情境題
使用者回報收到 -32602 Invalid or expired requestState,你想知道到底是過期、金鑰不對還是參數被改。該怎麼查?
統一的錯誤訊息是刻意設計——不給攻擊者可以逐步修正的線索,所以線索也不在回應裡:五個攻擊實驗全部拿到同一句。診斷的正道是伺服器 log,五種原因各有記號。A 做不到:解開 base64 之後是 AES-256-GCM 密文,notebook 實測連 people、vip 都找不到,更讀不到 exp;B 白忙——貼一百次都是同一句;C 是瞎槍打鳥,過期只是五種原因之一。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1 分鐘)——免費 CPU 環境即可,不需要 GPU
不想用 molab?下載 fastmcp4-state_ext.py 後在自己電腦
uvx marimo edit --sandbox fastmcp4-state_ext.py,依賴會自動安裝。