Serving
Two families, chosen by whether you own the runtime.
You already run on tokio
bridge.serve_stdio().await?; // one client over stdio
bridge.http().bind("127.0.0.1:8931")?.serve().await?; // many, Streamable HTTPBoth run until the connection closes. serve is also available for any rmcp transport (a bidirectional AsyncRead/AsyncWrite pair).
http() is the only door to HTTP, and bind is deliberately a separate step from serve: it is where the port becomes knowable.
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 is synchronous and needs no runtime — which is what lets the GUI side below use the same type.
You have a GUI event loop
Your app owns the main thread and has no runtime of its own, so the server goes to a background thread with its own:
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.spawn_stdio() is the same shape for stdio, which has no address to report.
Binding happens on your thread, which is what makes a port already in use an error you can act on rather than a running app sitting behind an unreachable server. spawn() itself cannot fail — the part that realistically does has already happened.
HTTP things live on the HTTP types
local_addr() is on BoundHttp, and token() is on HttpBuilder — neither is on GuiBridge or RunningBridge, which stdio also uses and for which neither has an honest answer. So there is no Option that is always None for half the callers, and no setting that silently does nothing. Want the port? Bind first: the shape of the API says so.
The one lifetime trap
RunningBridge::drop shuts the server down gracefully. If you let the handle fall out of scope at the end of setup, the server stops immediately — while your app keeps running, silently unreachable. RunningBridge::stop() does the same thing explicitly.
purview-gpui removes this trap entirely: it ties the handle to the gpui App's lifetime. See Setup & handlers.
Choosing a transport
| Transport | Clients | Who starts the process | Use for |
|---|---|---|---|
| stdio | One | The client | An agent launching your app as a subprocess |
| Streamable HTTP | Many, sharing one projection | The user | A long-running desktop app; several agents or a browser client |
stdio and a desktop app pull in opposite directions
stdio requires the client to spawn your process and own its stdin/stdout. But a desktop app is normally already running when the user decides to connect an agent, and a second process would open a second set of windows. Serving stdio from a GUI fits only when the agent is meant to launch the app; otherwise use HTTP — or have the second process detect the first, open no windows, and proxy stdio to it.
Where the client connects
The address is yours to choose, not something the GUI has to reveal. A fixed port is a constant you can document once, the way 9222 is Chrome's remote-debugging port:
Bridge::new().http().bind("127.0.0.1:8931")?.install(cx)The MCP endpoint is served at every path, so any URL on that port works. Connecting is then a line your users copy once — Claude Code, for example, takes an HTTP endpoint (claude mcp add --transport http myapp http://127.0.0.1:8931/mcp); check your client's docs for its own form.
Fixed ports do collide — with another app, or with a second copy of yours. Port 0 avoids that, at the cost of an address nobody can predict, which then has to reach the client somehow:
| Getting the address to the client | How |
|---|---|
| A fixed port | Document it. Nothing to display, nothing to copy. |
| Show it | BoundHttp::local_addr() — BoundBridge::local_addr() in gpui, or cx.purview_addr() from a view later on. Render it, or put a "copy the connect command" button behind it. |
| Write it out | At startup drop { "url": … } in a known file and delete it on exit; the client — or a small stdio↔HTTP proxy the client spawns — reads it. This is what lets a user's config stay valid across restarts on random ports. |
A button beats a label
"Copy the connect command" collapses the whole question into one click and no typos. The user never reads an address at all — which is the point.
Authorization
Loopback is not a permission boundary. Every process on the machine can reach the port, and so can any page in the user's browser: a POST to http://127.0.0.1:8931 from evil.com carries a perfectly legitimate Host header, and the DNS-rebinding check that ships by default lets it through (Origin is not validated by default).
A token closes that:
bridge.http().token(token) // core
Bridge::new().http().token(token) // purview-gpuiEvery HTTP request must then carry Authorization: Bearer <token>; anything else gets a 401. A browser page cannot set that header cross-origin without a CORS preflight this server never grants, and another local process would have to guess the value.
- Generate a fresh token per run and send it out with the address — the same button or file that carries the URL.
- The comparison is constant-time.
- stdio ignores it: no header to carry it, no listening port to protect.
Not the OAuth flow
This is a static bearer token for a local transport, not MCP's OAuth authorization. It works with any client that lets you set a request header; a client that accepts only a bare URL cannot present one.
By default the HTTP server only accepts loopback Host headers (DNS-rebinding protection). Expose it to a LAN through a reverse proxy rather than by loosening that.