Cloudflare Gateway 過去用網域名稱裡有沒有 mcp、路徑是不是 /mcp 或 /sse 猜一個連線是不是 MCP 流量——這個猜法連 https://tools.example.com/api 這種完全合法的 MCP endpoint 都會漏掉。推測起來,真正讓 Gateway 不用再猜的,不是偵測技術進步,而是 MCP 規格自己先把「連線建立時協商一次」的 initialize handshake 整個拿掉。
一個標頭,把影子 MCP 照了出來
Cloudflare 在 Gateway 裡把辨識 MCP 流量的方法,從「找 URL 裡有沒有關鍵字」換成「讀一個標頭」。這聽起來像單純的工程優化,但推測起來,背後真正的推手是 MCP 規格本身的版本演進——2025-11-25 版要求 client 在 initialize 完成後的每個請求帶上 MCP-Protocol-Version,2026-07-28 版乾脆把 initialize 這一步拿掉,讓每個 POST 都各自宣告版本。協定從「連線層協商一次」變成「請求層逐次宣告」,Gateway 才第一次有機會不靠猜測、直接讀一個欄位分辨 MCP 流量。
舊方法:URL pattern 的猜測遊戲
Cloudflare 最早想找出 MCP 流量,用的是 GraphQL Analytics API 回頭搜尋 Gateway 的 HTTP log,找 hostname 或路徑裡有沒有出現「hostnames containing mcp and common paths like /mcp or /sse」這種字串組合——這是事後的日誌搜尋,不是 Gateway 即時套用在每一段流量上的分類規則;在有 is_mcp 這種內建 selector 之前,想涵蓋 MCP 流量的管理者,得自己維護一份「像 MCP」的網域清單。問題是 MCP endpoint 的網址完全不必符合這個形狀。一個把工具介面掛在 https://tools.example.com/api 底下的伺服器,只要不含 mcp、不叫 /mcp 或 /sse,這種日誌搜尋就會漏掉它——Cloudflare 自己的說法是「They miss an MCP server at an ordinary URL like https://tools.example.com/api, which is not uncommon」。反過來,一個名字裡剛好帶 mcp 字樣、實際上跟 Model Context Protocol 毫無關係的服務,也可能被這套搜尋誤判成 MCP 流量——Cloudflare 也承認「And they can match an unrelated service that happens to use mcp in a hostname or path (unlikely, but we have seen it)」,但也強調這種情況並不常見,只是偶爾真的遇過。這是所有「看 URL 猜身份」方法共同的兩難:規則放寬就抓不準,規則收緊就漏得多,兩邊都靠一個跟協定語意完全無關的訊號硬撐。
拖動分隔線比較兩種判斷法 · 3 個範例請求
這套方法對負責寫 Allow / Block 政策的管理者來說,代價會直接反映在規則清單上——比較合理的推測是,如果團隊想涵蓋更多可能的 MCP endpoint,得靠把符合條件的關鍵字表越列越長——清單一長,誤判的表面積很可能也跟著變大。這是任何靠字串比對猜測協定身份的方法都會撞上的結構性限制,Cloudflare 的實作只是撞上的其中一個例子。猜測法的準確度天花板,取決於協定本身願不願意在 wire level 留下可辨識的訊號,跟猜測邏輯寫得多聰明無關。
推測起來,Cloudflare 沒有選擇繼續加大 URL pattern 的比對範圍,卡住的原因在協定本身沒把訊號做出來。字串比對這條路線可以一直加規則、一直調權重,但天花板早就在那裡——凡是跟協定語意脫鉤的訊號,準確度終究要靠猜。唯一能真正解決問題的做法,是等協定自己在 wire level 放進一個明確、逐請求都存在的欄位,這一步只有 MCP 規格自己能做,Gateway 端寫再聰明的比對演算法也補不上。
「wire level」這個詞的重點在於,Gateway 不需要修改任何一端的程式碼就能運作——它站在網路路徑中間,讀的是實際送出去的 HTTP 封包,不用回頭去問 client 或伺服器「你是不是 MCP」。這跟前面提到的 URL pattern 判斷法在運作層次上其實是一樣的,都是中介層對流量的旁觀式判讀;差別只在於這次判讀依據的東西,從一個跟協定語意無關的命名慣例,換成了協定自己定義、自己要求每個請求都要帶的欄位。
MCP-Protocol-Version:從連線協商到逐請求宣告
版本標頭不是憑空冒出來的。Streamable HTTP 這個 transport 本身在 2025-03-26 就存在了,「Streamable HTTP was introduced in protocol version 2025-03-26 as a replacement for the HTTP+SSE transport from protocol version 2024-11-05」,但那時候還沒有版本標頭這回事。2025-06-18 才第一次定義 MCP-Protocol-Version;到了 2025-11-25 版,規則收緊成「client MUST include the MCP-Protocol-Version HTTP header on all subsequent requests to the MCP server」——注意是 subsequent,initialize 那第一個請求本身仍然豁免,協定版本靠 initialize 交換時協商,標頭只是協商結果的鏡射。
2026-07-28 版把這個豁免拿掉了:「Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header」,不分是不是第一個請求。同一次改版連 session 機制都清掉了——「Removal of the GET stream endpoint」與「Removal of protocol-level sessions」是規格自己列出的變更項目,MCP-Session-Id 不再使用。versioning 頁面把這個分野講得很直白:「There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently」,並且把 2026-07-28 之後稱為 modern(逐請求 metadata 攜帶版本、身分、能力)、2025-11-25 之前稱為 legacy(靠 initialize handshake 建立連線層 session)。
這種轉變也不是溫和的向下相容。versioning 頁面的相容性矩陣寫得很直白:一個還停留在 legacy 語意、預期 initialize handshake 的 client,打上一個只支援 modern 版本的 server 會直接失敗——legacy client 不知道要送 per-request metadata,modern server 也不會回頭配合做 initialize;唯一能兩邊都討好的是 dual-era 實作,同時支援兩種語意,靠探測伺服器的回應內容決定要用哪一套。規格自己也承認這個斷裂的重量:「A server that supports only modern versions SHOULD name the protocol versions it supports in any error it returns to an initialize request, on any transport: legacy clients have no fall-forward mechanism, and this message may be the only diagnostic they can surface to users」。對還在跑舊版 client 的環境,這是協定語意層級的斷裂,遠不只是多一個標頭那麼簡單。
這個標頭能被 Gateway 這樣的中間層拿來當作可信訊號,背後還有一層協定自己的把關。規格規定標頭裡的版本值必須跟 request body 的 _meta 欄位裡攜帶的版本值一致,兩者對不上,伺服器要用 400 Bad Request 搭配 HeaderMismatch 錯誤拒絕請求。這個設計是衝著中間層而來的——如果標頭跟 body 可以各說各話,任何只在標頭層做判斷的裝置都可能被偽造的標頭誤導;把一致性檢查寫進協定本身,等於先把「標頭是可信來源」這件事釘死,Gateway 才敢把它當成 policy 依據,而不只是一個參考值。
2026-07-28 版另外定義了一個 server/discover 方法,讓 client 可以在送出任何其他請求之前,先問伺服器支援哪些協定版本,但這一步是 MAY,不是 MUST——client 也可以直接送出想要的請求,版本不合就等伺服器回 UnsupportedProtocolVersionError 再重試。這代表版本探測本身也是逐請求、錯誤驅動的模式,跟拿掉 initialize handshake 的整體設計精神一致:沒有強制的前置交握,只有「試了才知道」。
GET stream endpoint 被拿掉,代表 2025-11-25 版裡「client 開一條獨立 SSE 連線、等伺服器主動推訊息」這條路徑,在 2026-07-28 版不再存在。伺服器要跟 client 要更多資訊(例如需要使用者確認或一次額外的模型呼叫),得把需求包進當前請求的回應裡、讓 client 帶著答案重送同一個請求,不必另外開一條通道主動發話。這個變化跟拿掉 session 是同一個方向的簡化:協定不再假設有一條長期存在、承載雙向訊息的連線,每一次來回都是一個獨立、自我完備的請求與回應,這也讓 Gateway 這種只看單一 request/response 的中介層更容易完整理解一段互動。
標頭本身也不是萬能訊號。Cloudflare 在文件裡老實列出限制:「The initial request from a legacy client may not contain it, protocol versions earlier than 2025-06-18 did not define it, and local stdio, custom transport, or nonconforming traffic may never carry it」。也就是說,就算 2026-07-28 版已經要求每個 POST 都帶標頭,一個還停留在舊版協定的 client、或者走 stdio 而不是 HTTP 的連線,這個標頭訊號可能從頭到尾都不會出現。這個限制跟後面會談到的 Gateway 視野限制,其實是同一個問題的兩個面向:協定規則管得到 client 該怎麼寫,但管不到 client 有沒有照規則寫。
拖動時間軸看每個版本的標頭規則 · 4 個版本節點
互動圖表
協定版本從連線建立時講一次的資訊,變成 2026-07-28 起每個 POST 都必填的請求層欄位。
把這兩件事接起來看:Gateway 是一個站在 client 與伺服器之間、逐請求檢查封包的中介裝置,它天生就沒有跨請求維護 session 狀態的立場——要它去追蹤「這條連線的第 N 個請求之前有沒有做過 initialize」,等於要求它變成有狀態的協定參與者,這在中介層是很重的負擔。看得出來的是,只有當協定版本變成「每個請求都必須自帶」的欄位,Gateway 這種無狀態、逐封包檢查的裝置才有辦法單靠讀一個 request 就完成分類,不用去猜測、也不用去記錄前面發生過什麼。2025-11-25 版雖然已經要求標頭出現在大多數請求上,但 initialize 本身的豁免與背後仍存在的 session 概念,代表 wire-level 中介層在會話最開始那一刻依然無法單靠標頭判斷。2026-07-28 版把這個缺口徹底補上,讓 Gateway 這類角色第一次有了穩定可用的訊號——推測起來,這個轉變的關鍵動力是規格演進本身。
is_mcp 與 mcp_portal:policy 端看得到什麼
Gateway 把讀到的標頭包成一個 policy 可以直接引用的布林值:「If Gateway detects the MCP-Protocol-Version header on a TLS-inspected request, the value is true, and an administrator can use it in an Allow or Block policy」。這句話裡有一個容易被忽略的前提——TLS-inspected request。experimental.is_mcp 只在 Gateway 已經把這段流量解密過的情況下才有意義;沒解密,Gateway 連標頭都碰不到,這個 selector 根本不會被觸發判斷。除了 is_mcp,Gateway 另外用一個叫 mcp_portal 的 Traffic Source 標籤區分「這段流量有沒有經過 MCP Portal 代理」,Cloudflare 給的 baseline 規則範例是:「Any detected MCP traffic that did not arrive through a Portal gets blocked; traffic that came through the Portal is unaffected」。policy 端操作的是兩個獨立維度:「這是不是 MCP」與「有沒有經過核准的入口」,各自獨立生效,不是單一的允許 / 拒絕開關。
這種兩維度設計背後有一個判斷順序:policy 引擎得先確定 is_mcp 為真,mcp_portal 這個標籤才有意義去比對;一段流量如果從一開始就沒被判定成 MCP,不管它有沒有經過 Portal,都不會落入這條 baseline 規則的射程。這代表 mcp_portal 標籤解決的是「已經確認是 MCP 的流量該怎麼處理」這個問題;「怎麼把所有 MCP 流量都找出來」是另一件事,成敗完全繫在 is_mcp 判定準不準這一步上。
這裡可以合理延伸一步:is_mcp 與 mcp_portal 合起來,除了做即時的 Allow / Block,也可以拿來做流量盤點。把兩個維度交叉起來看,一個組織大致能回答「我們到底有多少 MCP 流量、其中多少走了核准的入口」這種問題,不必先手動列出所有已知的 MCP 伺服器再逐一核對。這種盤點能力,比單純的封鎖規則更貼近「先看清楚現狀,再決定要不要動手」的資安工作流程——只是這一層用途來源沒有明講,是順著兩個 selector 的定義往下推的延伸。
這個用詞選得謹慎。baseline 規則用的字是「unaffected」,比「allowed」弱一截——它只暗示走 Portal 的 MCP 流量不會被這條規則額外處理,並不代表自動獲准;Portal 本身的 Access policy 才是決定這段流量最終能不能通過的地方。is_mcp 與 mcp_portal 這兩個訊號負責的是「要不要套用這條 baseline block 規則」,最終准不准這個連線,仍然是 Portal 的 Access policy 或組織其他既有政策的職責,這兩層判斷不能混為一談。
切換三個開關看 is_mcp 與 policy 動作如何變化 · 3 個開關、8 種組合
三個開關都開:TLS 已解密、標頭存在、流量經過 Portal——Gateway 判定為 MCP,且因為走了 Portal,baseline 規則不封鎖它。
兩種不同的繞過:shadow MCP 與 Portal bypass
is_mcp 與 mcp_portal 這兩個訊號合起來要處理的,其實是兩種性質不同的問題。Shadow MCP 的定義是「A connection to a server the organization has not approved. An employee finds the server in a repository, a product guide, or a message from a colleague」——重點是伺服器本身從未進入受管理的清單,員工自己在某個 repo、產品文件或同事丟過來的訊息裡找到它、就連上去了。Portal bypass 則完全不同:「An approved server that the organization has placed in an MCP Portal, but an employee connects to its upstream URL directly and skips the Portal's Access policy」——伺服器是核准過的、已經放進 Portal,問題出在連線路徑,員工直接打上游 URL,把 Portal 的 Access policy 整個繞過去。一個是未知的伺服器,一個是已知伺服器的未受控路徑,威脅來源不一樣,該修的地方也不一樣。
| 情境 | 伺服器狀態 | 進入方式 | mcp_portal 標籤 |
|---|---|---|---|
| shadow MCP | 組織從未核准 | 員工自行找到並連上(repo、文件、同事訊息) | 沒有 Portal 標籤——因為根本沒放進 Portal |
| Portal bypass | 組織已核准、已放進 Portal | 員工繞過 Portal、直連上游 URL | 沒有 Portal 標籤——因為這次連線跳過了 Portal |
兩者最後落在 mcp_portal 這個訊號上的結果其實一樣:都沒有 Portal 標籤。合理的推論是,Gateway 光靠這個標籤沒辦法區分「這台伺服器從未被核准」跟「這台伺服器核准過、但這次連線繞過了 Portal」——兩種情境在 baseline enforcement 規則眼裡長得一模一樣,都會被判定為未經 Portal 的已偵測 MCP 流量而擋下。要真正分清楚兩者,得回頭比對組織自己維護的核准清單,這已經超出 is_mcp/mcp_portal 這兩個訊號能單獨回答的範圍。
從防禦的角度看,這個差異決定了該從哪裡下手補洞。Shadow MCP 的根源是核准流程本身沒覆蓋到——員工找到的伺服器從來沒進過任何清單,要堵住這個缺口,得從「怎麼讓員工不用自己找」下手,例如把常用工具都先放進 Portal。Portal bypass 的根源不一樣,伺服器已經在清單裡,缺口出在「知道規則但沒有被擋住」,要堵住這個缺口,得靠網路層或 DNS 層讓上游 URL 本身連不通,核准流程在這裡幫不上忙。
比較可能的情況是,這兩種繞過在實務上會混在一起發生——一個團隊如果同時有很多員工在嘗試各種 MCP 工具,兩種情況通常同時存在:一部分人根本不知道有 Portal 這回事,直接找了未核准的伺服器;另一部分人知道 Portal 存在,但覺得直連比較方便就跳過了。單一套 policy 很難同時處理兩種心態不同的行為,這可能是 Cloudflare 把兩者定義成獨立情境、沒有合併成一個「未經授權的 MCP 連線」分類的原因。
三層控制點的覆蓋與盲點
Gateway 只是三個可能的控制點之一。Cloudflare 把整條路徑上的介入時機分成三層:client hook、network proxy、server middleware,各自的視角與盲點都不一樣。
點擊任一層查看它的覆蓋範圍與盲點 · 3 層控制點
三個控制點:request 從 client 出發、經過網路、抵達伺服器
click a layer above
client hook · 覆蓋與盲點
時機最早:「A client hook can run after the model selects a tool but before the client serializes the request」,攔截點在工具已選定、還沒送出去之前。
盲點:規則不會自動跨 client 生效,安全團隊得在每一個 client 實作裡各自重建同一套邏輯才有用。
network proxy · 覆蓋與盲點
視角最廣:「The network layer has the widest lens to detect remote MCP traffic on managed paths」——但這個「最廣」有限定詞,只涵蓋走過 managed path 且被 TLS 解密過的流量。
盲點:「Proxies cannot see local stdio calls or off-network traffic」,本機呼叫與離網連線完全在視野外。
server middleware · 覆蓋與盲點
context 最豐富:「The server has the richest execution context... this is the last point where the request can be denied before the tool runs」,是拒絕請求的最後機會。
盲點:比較合理的看法是,這一層只在請求真的送到這台伺服器時才生效——擋不住流量根本沒抵達這裡之前發生的事。
三層合起來看,沒有一層單獨涵蓋全部路徑——client hook 管得到「選了什麼工具」但管不到跨 client 一致性,network proxy 管得到「走過網路的流量」但管不到 stdio 與離網連線,server middleware 管得到「真正執行前的最後一刻」但前提是流量真的送到那裡。Cloudflare 自己也把這套 Gateway 方法的邊界寫得很清楚:「Direct encrypted traffic must pass through TLS decryption before Gateway can inspect these headers, and local stdio servers, off-network connections, Do Not Inspect traffic, and requests that never traverse Gateway remain outside this view」。這段話值得逐字讀——TLS 解密是硬性前提,stdio、離網連線、Do Not Inspect 名單裡的流量、以及根本沒經過 Gateway 的請求,一律不在這套方法的視野裡。
站得住腳的猜測是,這三層要疊加起來才划算,不是彼此替代的選項——client hook 抓得到 network proxy 抓不到的東西(例如流量根本沒走過 managed path,但在應用層被攔下),network proxy 抓得到 client hook 覆蓋不到的地方(沒有重新實作規則的 client),server middleware 則是前兩層都放行之後,工具即將執行的最後一刻再把關一次。任何單一層都留有清楚的縫隙,三層加總起來縫隙會變小,但不會變不見。這篇文章引用的來源完全沒有提到三層同時部署之後,縫隙實際縮小了多少。
這裡有一個來源沒有明講的前提:mcp_portal 這個標籤要能成立,Portal 本身也得是一段會被 Gateway 看到的流量。Portal 要嘛部署在 Gateway 管得到的路徑上,要嘛在轉發流量時留下某種可辨識的痕跡,Gateway 才有辦法判斷「這段連線是不是從 Portal 出來的」。這跟 is_mcp 依賴 TLS 解密的邏輯,其實是同一種限制的不同面向:這整套系統的每一層判斷,都建立在「流量真的會經過某個可觀測的點」這個共同前提上——流量一旦繞開這個點,is_mcp 與 mcp_portal 都無從判斷起。
來源裡沒講的部分,也該老實列出來。Gateway 的分類機制被形容成「detection built from patterns we observe across the millions of requests that traverse the Cloudflare network every day」,這句話沒有交代具體的 heuristic 邏輯、判斷閾值、或者這套機制跟單純檢查標頭存在與否之間還有什麼額外的模式比對;誤判率——不管是把非 MCP 流量錯判成 MCP、還是漏掉真正的 MCP 流量——完全沒有公開數字;TLS 解密加上逐請求標頭檢查對延遲會造成多少影響,來源同樣隻字未提。不能把這些空白直接當成「應該很小」或「可以忽略」,只能標記成未知,等 Cloudflare 或第三方測試補上。
What this enables:Gateway 現在能在 TLS 解密後、不靠 hostname 或路徑猜測,直接讀一個協定層標頭把 MCP 流量從其他流量裡分出來,再用 is_mcp 與 mcp_portal 兩個獨立的 policy selector,把「這是不是 MCP」和「有沒有經過核准的 Portal」分開判斷與執行。