对外提供服务
一共两类做法,取决于运行时(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 是同步的,不需要任何运行时——正因如此,下面 GUI 那一侧用的是同一个类型。
你有一个 GUI 事件循环
你的应用占据主线程,自身又没有运行时,因此服务端要放到一个自带运行时的后台线程上:
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 重绑定检查会放它过去(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 重绑定攻击)。如需在局域网内访问,请通过反向代理暴露,而不要放宽这项限制。