Skip to content

提供服務

分成兩大類,取決於是否擁有 runtime。

你已經跑在 tokio 上

rust
bridge.serve_stdio().await?;                       // one client over stdio
bridge.http().bind("127.0.0.1:8931")?.serve().await?;  // many, Streamable HTTP

兩者都會一直執行直到連線關閉。另外還有 serve,可搭配任何 rmcp 傳輸層(一組雙向的 AsyncReadAsyncWrite)。

http() 是進入 HTTP 的唯一入口;而 bind 之所以特意與 serve 分成兩步,是因為連接埠正是在這一步才變得可知:

rust
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 的背景執行緒上:

rust
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 上——兩者都不在 GuiBridgeRunningBridge 上,因為 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 的遠端除錯:

rust
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.comhttp://127.0.0.1:8931 發一個 POST,帶的 Host 標頭完全合法,預設啟用的 DNS rebinding 檢查會放它過去(Origin 預設並不驗證)。

一個 token 就能擋住這條路:

rust
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)。若要在區域網路上開放,請透過反向代理,而不是放寬這項限制。