提供服務
分成兩大類,取決於你是否擁有 runtime。
你已經跑在 tokio 上
bridge.serve_stdio().await?; // one client over stdio
bridge.http().bind("127.0.0.1:8931")?.serve().await?; // many, Streamable HTTP兩者都會一直執行直到連線關閉。另外還有 serve,可搭配任何 rmcp 傳輸層(一組雙向的 AsyncRead/AsyncWrite)。
http() 是進入 HTTP 的唯一入口;而 bind 之所以特意與 serve 分成兩步,是因為連接埠正是在這一步才變得可知:
let bound = bridge.http().bind("127.0.0.1:0")?; // 0: let the OS choose
println!("MCP endpoint: http://{}", bound.local_addr());
bound.serve().await?;bind 是同步的,不需要任何 runtime——正因如此,下面 GUI 那一側用的是同一個型別。
你有 GUI 事件迴圈
你的應用程式佔用了主執行緒,而且本身沒有 runtime,因此伺服器會被放到一條擁有自己 runtime 的背景執行緒上:
let bound = bridge.http().bind("127.0.0.1:0")?; // 0: let the OS choose
let addr = bound.local_addr(); // the port it actually got
let running = bound.spawn(); // background thread + runtime
// run your GUI event loop here…
// keep `running` alive: dropping it stops the server.stdio 那側是同樣的形狀:spawn_stdio(),只是沒有位址可報。
繫結發生在你自己的執行緒上,所以連接埠被佔用是一個你能當場處理的錯誤,而不是應用程式照常啟動、伺服器卻根本連不上。spawn() 本身則不可能失敗——真正會失敗的那一步已經過去了。
HTTP 專屬的東西長在 HTTP 專屬的型別上
local_addr() 在 BoundHttp 上,token() 在 HttpBuilder 上——兩者都不在 GuiBridge 或 RunningBridge 上,因為 stdio 也會用到它們,而它們對這兩件事都給不出誠實的答案。於是既不存在「對一半呼叫者永遠是 None」的 Option,也不存在「設了卻默默不起作用」的選項。想要連接埠?先 bind——這件事由 API 的形狀來告訴你。
唯一的生命週期陷阱
RunningBridge::drop 會優雅地關閉伺服器。如果你讓這個控制柄在初始化結束時離開作用域,伺服器就會立刻停止——而你的應用程式仍在執行,只是默默地再也無法被連上。RunningBridge::stop() 則是明確地做同一件事。
purview-gpui 徹底消除了這個陷阱:它把控制柄的生命週期綁定到 gpui App 上。參見設定與處理器。
選擇傳輸方式
| 傳輸方式 | 客戶端數 | 由誰啟動行程 | 適用於 |
|---|---|---|---|
| stdio | 一個 | 客戶端 | Agent 把你的應用程式當成子行程啟動 |
| Streamable HTTP | 多個,共用同一份投影 | 使用者 | 長時間執行的桌面應用程式;多個 Agent 或瀏覽器客戶端 |
stdio 與桌面應用程式的方向是相反的
stdio 要求由客戶端啟動你的行程並掌管它的 stdin/stdout。但使用者決定接上 agent 時,桌面應用程式通常早就在執行了,再開一個行程只會多出一套視窗。從 GUI 提供 stdio,只在「本來就該由 agent 拉起這個應用程式」時才合適;否則請用 HTTP——或是讓第二個行程發現第一個已存在後不開任何視窗,只把 stdio 代理給它。
客戶端從哪裡連進來
位址是由你決定的,不是非得由 GUI 告訴使用者的東西。固定連接埠就是一個可以寫進文件、講一次就夠的常數,正如 9222 之於 Chrome 的遠端除錯:
Bridge::new().http().bind("127.0.0.1:8931")?.install(cx)MCP 端點在所有路徑上都提供服務,所以那個連接埠上任何 URL 都能用。於是「怎麼連」就變成使用者複製一次的一行指令——例如 Claude Code 接受 HTTP 端點(claude mcp add --transport http myapp http://127.0.0.1:8931/mcp);具體形式請查閱你所用客戶端的文件。
固定連接埠確實會撞車——和別的應用程式,或和你自己的第二個實例。連接埠 0 能避開這點,代價是位址誰也預測不了,於是它必須以某種方式送到客戶端手上:
| 把位址送到客戶端 | 做法 |
|---|---|
| 固定連接埠 | 寫進文件即可。不用顯示,也不用複製。 |
| 顯示出來 | BoundHttp::local_addr();gpui 裡初始化現場用 BoundBridge::local_addr(),事後從某個 view 則用 cx.purview_addr()。繪製到介面上,或藏在一個**「複製連線指令」**按鈕後面。 |
| 寫到外部 | 啟動時把 { "url": … } 寫進一個約定位置的檔案,結束時刪掉;由客戶端——或由客戶端拉起的一個 stdio↔HTTP 小代理——去讀。這正是讓使用者的設定在「每次重啟換連接埠」的情況下依然有效的辦法。 |
一個按鈕勝過一行文字
「複製連線指令」把整個問題壓縮成一次點擊,也不會打錯字。使用者從頭到尾都不需要讀到一個位址——這才是重點。
授權
loopback 不是權限邊界。本機上任何行程都能碰到這個連接埠,使用者瀏覽器裡的任何頁面也可以:從 evil.com 向 http://127.0.0.1:8931 發一個 POST,帶的 Host 標頭完全合法,預設啟用的 DNS rebinding 檢查會放它過去(Origin 預設並不驗證)。
一個 token 就能擋住這條路:
bridge.http().token(token) // core
Bridge::new().http().token(token) // purview-gpui此後每個 HTTP 請求都必須帶上 Authorization: Bearer <token>,否則回 401。瀏覽器頁面在跨來源請求裡設不了這個標頭——那需要一次本伺服器永遠不會通過的 CORS 預檢;而本機上的其他行程則得把 token 猜出來。
- 每次執行產生一個新 token,和位址一起送出去——用承載 URL 的同一個按鈕或同一個檔案。
- 比較是常數時間的。
- stdio 會忽略它:既沒有標頭可以承載它,也沒有監聽的連接埠需要保護。
這不是 OAuth 流程
這是給本機傳輸用的靜態 bearer token,不是 MCP 的 OAuth 授權。它適用於任何允許你設定請求標頭的客戶端;只接受一個裸 URL 的客戶端無法出示它。
預設情況下,HTTP 伺服器只接受 loopback 的 Host 標頭(防範 DNS rebinding)。若要在區域網路上開放,請透過反向代理,而不是放寬這項限制。