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 传输(一对双向的 AsyncRead/AsyncWrite)。

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 是同步的,不需要任何运行时——正因如此,下面 GUI 那一侧用的是同一个类型。

你有一个 GUI 事件循环

你的应用占据主线程,自身又没有运行时,因此服务端要放到一个自带运行时的后台线程上:

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 重绑定检查会放它过去(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 重绑定攻击)。如需在局域网内访问,请通过反向代理暴露,而不要放宽这项限制。