一個值抄兩份,還要求逐字相同:拆開 MCP 的 Mcp-Param 標頭
一個 tools/call 請求打到你的 nginx 上,在它把 body 讀完之前,那台機器到底知道些什麼?
知道方法是 POST,知道路徑是 /mcp。就這樣。哪個 tool、哪個租戶、要跑在哪個 region,全部埋在 JSON-RPC 的 body 裡,而 JSON-RPC 的設計本來就是把一切塞進那一坨 JSON:method 在裡面,參數也在裡面,URL 從頭到尾只有一個。你擺在前面負責路由或限流的那台東西,看到的每一個請求長得一模一樣。
2026-07-28 這版規格的處理方式,是把幾個欄位抄一份到 HTTP 標頭上。
一個合規的請求,標頭上多了三行
規格自己給的範例,是 Google Cloud Spanner 的 execute_sql:
1 | POST /mcp |
標頭上那幾個值,body 裡一個都不少。tools/call 出現兩次,execute_sql 出現兩次,us-west1 出現兩次。同一份資料寫了兩遍,中間沒有任何轉換。
先把這幾行拆開,各自是誰放上去的。
前兩個是規格寫死的
Mcp-Method 的來源是 body 的 method,照抄。Mcp-Name 的來源是 params.name 或 params.uri,而且只用在 tools/call、resources/read、prompts/get 這三種請求上。規格給這兩格下的字是 REQUIRED for compliance。
範例裡的 MCP-Protocol-Version 也是每個 POST 必帶,但它不在那張標準標頭的表格裡,規格把它單獨列了一節。等一下會回來談它,因為它才是把整條信任鏈收尾的那一格。
第三個是 server 自己決定的
Mcp-Param-Region 這一行沒有寫在規格裡。它是 server 在自己的 tool 定義上宣告出來的,靠的是一個叫 x-mcp-header 的 JSON Schema 擴充屬性:
1 | { |
x-mcp-header 直接掛在要被鏡射的那個屬性的 schema 上,它的值只是 header 名字的後半段,client 拿去組成 Mcp-Param-{name}。所以 "x-mcp-header": "Region" 加上呼叫時的 "region": "us-west1",組出來就是 Mcp-Param-Region: us-west1。
取值的規則寫得很死:讀那個被標記屬性的完整路徑上的值,路徑上沒有值就整格省略,不補預設、不猜。
能抄的東西比你想的窄
規格對 x-mcp-header 的值列了六條限制,說穿了是三件事。
第一件是名字本身要長得像一個合法的 HTTP 欄位名:不得為空、必須符合 RFC 9110 第 5.1 節的 field-name token 語法、不得含控制字元(包括 CR 跟 LF),而且在同一份 inputSchema 裡,所有 x-mcp-header 的值必須大小寫不敏感地唯一。
第二件是型別要窄。只能用在 integer、string、boolean 這三種,number 明文不准;整數還必須落在 IEEE754 雙精度浮點數能安全表示的範圍裡,也就是 −253+1 到 253−1。規格沒解釋為什麼獨獨排掉 number,不過它在另一處留了一個旁證:驗證整數參數時,server SHOULD 用數值比對而不是字串比對,42.0 跟 42 要算相等。我的理解是浮點數轉成字串沒有唯一寫法,兩邊各自轉一次就可能長得不一樣,比對這件事會直接爛掉。這段是我的推論,規格沒有寫。
第三件最有意思,也最容易絆到人:被標記的屬性必須是靜態可抵達的。規格的定義是,從 schema 根部走到那個屬性,路徑上每一步都得是 properties 這個鍵。中間不准穿過 items(或任何陣列關鍵字)、不准穿過 oneOf / anyOf / allOf / not、不准穿過 if / then / else、也不准穿過 $ref。巢狀物件是可以的,只要每一層都是 properties。標在其他任何地方,這個註記連同整份 tool 定義一起判為無效。
這條限制等於在說:取值這個動作必須是純機械的。如果要先解掉一個 oneOf 才知道該從哪個欄位取,那 client、server、中間那台 gateway 就有機會各自算出一個答案,而整套設計的前提偏偏是三方一定要算出同一個值。規格沒寫這個理由,是我自己推的。
一個 tool 寫歪,不會讓整包陪葬
違規的處置方式有個轉折。走 Streamable HTTP 的 client MUST 拒絕任何 x-mcp-header 違規的 tool 定義,但「拒絕」的定義是把那一個 tool 從 tools/list 的結果裡剔除掉,並且 SHOULD 記一筆 warning 說明是哪個 tool、為什麼被剔除。規格把理由寫在同一段:這樣一份寫壞的 tool 定義就不會害得其他正常的 tool 全部不能用。
這裡還有一個不對稱:server 要不要用 x-mcp-header 是選配的,但 client 必須支援。只要 server 的 tool 定義掛了這個註記,合規的 client 就得照做。走 stdio 的 client 則可以整個忽略它。
規格沒說這個不對稱是怎麼決定的。我的猜測是,反過來設計會讓整件事失去意義:如果 client 那邊是選配,server 掛了註記也不知道對方會不會照做,中間層就不能拿那個標頭當判斷依據。現在這樣,只要 server 想用就一定有人配合。
不是每個值都塞得進標頭
RFC 9110 規定 HTTP 標頭的值只能由可見 ASCII 字元(0x21 到 0x7E)、空白和水平 tab 組成。"Hello, 世界" 這種東西就進不去。
規格的解法是一個 sentinel 格式:
1 | Mcp-Param-{Name}: =?base64?{Base64EncodedValue}?= |
前綴 =?base64? 跟後綴 ?= 大小寫敏感,必須逐字照抄成小寫。Mcp-Name 的值走同一套規則,因為 tool 名跟 prompt 名只被 SHOULD 約束在安全字元裡,規格得留這條後路。
規格列的觸發條件是這些:
| 原始值 | 原因 | 編碼後的標頭值 |
|---|---|---|
"us-west1" |
純 ASCII | Mcp-Param-Region: us-west1 |
"Hello, 世界" |
含非 ASCII | Mcp-Param-Greeting: =?base64?SGVsbG8sIOS4lueVjA==?= |
" padded " |
前後有空白 | Mcp-Param-Text: =?base64?IHBhZGRlZCA=?= |
"line1\nline2" |
含換行 | Mcp-Param-Text: =?base64?bGluZTEKbGluZTI=?= |
"=?base64?literal?=" |
本身符合 sentinel 樣式 | Mcp-Param-Val: =?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?= |
最後一列才是這張表的重點。一個純 ASCII、完全合法的字串,只因為它本身長得像編碼標記,就必須被編碼一次。不然 server 解開標頭的時候沒辦法判斷這是編過的還是原本就長這樣。
沒有這一條會怎樣?client 把 =?base64?literal?= 照原樣送出去,server 讀到它,看到前綴後綴都在,就照規矩解一次 base64,解出來的東西不會是原來那個字串。接著就是後面那節要講的事:server 拿標頭跟 body 逐格比對,對不上,整個請求回 400。一個雙方都照著規矩走的請求,卡在「這幾個字元是標記還是內容」這個沒有答案的問題上。規格的解法是不讓這個問題存在:凡是長得像標記的一律再編一次,解碼端就只剩一條路可走。
現在才輪到「為什麼要抄兩份」
把同一個值放在兩個地方,直覺上就是壞設計。規格在 Request Metadata 那節給了正面的理由,說鏡射是為了讓中間層(load balancer、gateway、可觀測性工具)不必解析 body 就能路由與檢查請求。這句是規格自己寫的。
但真正的重量在反面。你抄了一份出來,就等於在網路上製造了同一件事的兩份說法,而兩份說法一旦分岔,中間每一台照著標頭做決定的機器都會開始對著錯的東西生效。
規格把這件事寫在 Server Validation 那節,原文是:
This prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value).
這就是那條看起來最合理的死路。標頭本來就是給 LB 看的、body 本來就是給 server 執行的,各取所需,誰都沒做錯事。問題是我可以在標頭寫 Mcp-Param-Region: us-west1,在 body 裡寫 "region": "eu-west1"。LB 照標頭把我丟進限制比較寬鬆的那一區,server 照 body 跑在另一區,所有掛在標頭上的策略(路由、限流、租戶隔離、WAF 規則)全部對著一份不會被執行的資料生效。事後翻 log 想搞懂為什麼權限沒擋住,你就有得查了。
所以規格把這條路封死:任何會處理 body 的 server,MUST 驗證標頭的值(base64 編過的要先解碼)跟 body 裡對應的值相符,不符就回 HTTP 400 Bad Request,附 JSON-RPC error code -32020,名字叫 HeaderMismatch。
判定失敗的情況規格也列了:必填的標準標頭(MCP-Protocol-Version、Mcp-Method、Mcp-Name)缺一個、標頭值跟 body 對不上、標頭值含非法字元。
參數本身的處置則是對稱的,兩個角色各有各的義務:
| body 裡的參數 | client 要做的 | server 要做的 |
|---|---|---|
| 有值 | 必須帶對應的標頭 | 必須驗 |
是 null,或根本不在 arguments 裡 |
必須省略標頭 | 不得預期它出現 |
| 有值,但 client 沒帶標頭 | 這種 client 不合規 | 必須拒絕 |
第三列其實是第一列的失敗版本。
client 收到 HeaderMismatch 之後該做什麼,規格也給了建議動作:先去叫一次 tools/list 看那個 tool 的 inputSchema 有沒有變,再帶著正確的標頭重送原本的請求。這個建議背後的假設很實際:它預設你會撞到 HeaderMismatch,多半是因為 server 換了 schema,而你手上那份已經過期。
標頭能不能信,取決於鏈條末端有沒有人驗
規格對中介設備另外給了一條建議。如果中介要照鏡射出來的標頭執行策略,例如照租戶路由或限流,它 SHOULD 先確認 MCP-Protocol-Version 標的是一個「要求做標頭與 body 比對」的版本。版本更舊、或這個標頭根本不在,中介 SHOULD 拒絕該請求,而不是去信任一個沒人驗過的標頭值。
順帶一提,不認得 Mcp-Param-{Name} 的中介 server MUST 原樣轉發並忽略它,這是 HTTP 語意規範本來就有的要求。
這兩條加起來,整套機制的信任鏈就講完了。標頭之所以可以信,不是因為它是標頭,是因為鏈條末端一定站著一台會逐格比對的 server。少了那一環,Mcp-Param-Region 就只是請求方自己填的一行字,跟 User-Agent 沒有兩樣。
我沒有架過 gateway 實際跑這套鏡射,也沒有去查任何一家 SDK 把 x-mcp-header 實作到什麼程度。上面每一條都是 2026-07-28 這版規格的文字,不是實跑結果;標了「我的理解」「我自己推的」那兩段,規格沒有寫。
它值不值得,看你前面站了什麼
把同一個值放兩個地方,在這裡我認為不算壞設計。壞的是放了兩份卻沒有人負責讓它們一致。這套規格真正做的事,是把「誰是事實來源」從一個大家各自假設的問題,變成一條寫在紙上、違反就回 400 的規則:body 永遠是事實,標頭只是它的投影,投影對不上,整個請求作廢。
什麼情況下我會覺得這套不值得?你的 server 前面什麼都沒有的時候。沒有 gateway、沒有 LB、沒有 WAF,client 直接 stdio 連進來,那所有鏡射都只是成本。規格自己留了這個出口,走其他 transport 的 client MAY 完全忽略 x-mcp-header。
把深埋的欄位抄一份到外層,好讓不解析 payload 的中間層也能做決定,這一招你到處都做過。HTTP 標頭本身就是這樣來的:body 要解析才懂,標頭不用。訊息佇列的 message attribute 是這樣,資料庫的索引也是這樣,把一個欄位複製出來,讓不想掃全表的人找得到。
所以下次你想把某個值複製一份出去給別人看,該問的問題不是「複製哪幾個欄位」。該問的是:兩份對不上的時候誰說了算,以及誰負責發現。這套規格的答案寫得很死,body 說了算,處理 body 的那台機器負責發現,發現了就回 400。把後面兩句拿掉,設計圖看起來一模一樣,但你已經悄悄多養了一個事實來源。









