From ecf826cd583f9ac189f08798ed07a912e535c727 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 21:08:05 +0800 Subject: [PATCH 01/46] =?UTF-8?q?docs(acp):=20add=20MCP-over-ACP=20browser?= =?UTF-8?q?-control=20implementation=20blueprint=20(=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the north-star (LLM operating the browser via MCP over /acp) into T0–T7 with sub-tasks, an OpenAB-side vs extension-side ownership split meeting at the MCP-over-ACP wire contract (T4), and the key findings that reshape the work: the agent→client request direction already exists on the downstream hop (request_permission is auto-replied in openab-core, so T1 is a relay not green-field), and mcpServers is currently [] (T5 injects a core proxy). Suggested order + which items are heavy. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 84 ++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 7757cde7d..99169f6c3 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -102,3 +102,87 @@ perceive→act tool loop), but generalized: (a) targets the **user's real Chrome logged-in), not a sandbox; (b) action surface is **extension-defined MCP tools** (DOM-semantic or screenshot), not a model-specific tool; (c) **model-agnostic** — any MCP-capable agent can use it. + +## 7. Implementation blueprint (task breakdown) + +North-star = the agent's LLM autonomously operating the user's browser via MCP tools +tunnelled over `/acp`. The base (PR that revives #1260) ships the 1:1 chat surface and the +**generated v1 wire types** (`acp_schema`, already committed) — one of the four critical- +path items is therefore done. What remains splits cleanly into an **OpenAB (server) side** +and an **extension (client) side**, meeting at a single **MCP-over-ACP wire contract** (T4) +so the two can proceed largely in parallel once that contract is fixed. + +### Findings that reshape the work +- The **agent→client REQUEST direction already exists on the downstream hop**: + `openab-core/src/acp/connection.rs` receives `session/request_permission` from the agent + and currently **auto-replies** it (~L252). So T1 is not green-field — it is *relaying* + those downstream requests up to the `/acp` client (and the response back) instead of + auto-answering them. +- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784). + Giving the agent browser tools = **injecting a core-side proxy MCP server** into that list + (T5) that tunnels `tools/*` to the extension. + +### Ownership +- **OpenAB side** (`feat/acp-mcp-browser`): T1, T2, T4 (contract + core routing), T5. +- **Extension side** (katashiro): T6; plus the client halves of T3 (respond to + `request_permission`) and T4 (serve MCP over the tunnel). +- **Both**: T7. + +### Tasks + +**T0 — Spike (do first; de-risks everything).** PoC: give `cursor-agent` a non-empty +`mcpServers` pointing at a mock MCP server and confirm the LLM actually discovers +(`tools/list`) and calls (`tools/call`) a tool; confirm a downstream agent→client request +can be relayed and answered. If this doesn't hold, the browser goal needs a different path. + +**T1 — agent→client REQUEST direction (relay).** +- 1.1 Decide to relay downstream requests (`request_permission`, later MCP) to the `/acp` + client instead of auto-replying; enumerate the relayed methods. +- 1.2 Gateway outbound request path: `acp_server` sends an agent-initiated REQUEST + (method + id) to the client and keeps a pending-response map (`id → oneshot`). +- 1.3 Read loop distinguishes an inbound **client response** (`id` + `result`/`error`, no + `method`) from a client request, and routes responses to the pending map. +- 1.4 core↔gateway bridge: relay the downstream request up + the client's response back + down to the agent. +- 1.5 Round-trip tests (agent request → client → response → agent). + +**T2 — migrate `acp_server` to generated typed wire (bidirectional surface).** +- 2.1 Construct response payloads from `acp_schema` types (the deferred construction + migration). 2.2 Type the new bidirectional messages (`request_permission`, MCP tunnel). + 2.3 Round-trip validate against real traffic (ACP trace mode). + +**T3 — `session/request_permission` end-to-end.** Largely the first concrete case of T1 +(relay the request, extension consent UX, relay the response) — folds into T1, not a +separate large task. + +**T4 — MCP-over-ACP tunnel framing.** +- 4.1 Fix the **wire contract**: how MCP JSON-RPC (`tools/list` / `tools/call` / results) + is multiplexed over `/acp` (method namespace, e.g. `_mcp/*`; request/response + correlation; framing). 4.2 Gateway routes MCP-namespaced messages between the `/acp` + client and core. 4.3 Contract doc (the spec the extension implements). 4.4 Mock-MCP- + client-over-tunnel tests. + +**T5 — OpenAB core = MCP proxy/aggregator.** +- 5.1 A core-side local MCP server (proxy) the agent connects to via `mcpServers`. +- 5.2 Inject that proxy into the downstream `mcpServers` (currently `[]`). +- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel. +- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent). +- 5.5 `rmcp` wiring (already used by `openab-agent`) + tests. + +**T6 — extension = MCP server + browser tools** (katashiro). +- 6.1 MCP server role over the outbound `/acp` WS (`tools/list` / `tools/call`). +- 6.2 DOM-semantic tools: `click(selector)` / `read_dom`(snapshot) / `navigate` / + `screenshot` / `type(selector, text)`. 6.3 Execute in the active tab + (`chrome.scripting` / content script + permissions). 6.4 Consent UX for + `request_permission`. 6.5 Tests. + +**T7 — integration + e2e + deploy.** +- 7.1 Full loop: `tools/list` → LLM calls `browser.click` → extension executes → result → + LLM continues. 7.2 A browser-loop e2e (extend `scripts/acp-ws-smoke.py`). + 7.3 Rebuild + redeploy Falcon. 7.4 Finalize this ADR. + +### Suggested order +T0 spike → T1 (+ T3 as its first case) → T2 → **T4 (fix the wire contract)** → T5 → then +T6 in parallel against the contract → T7. The heavy items are T1 (the direction), T4/T5 +(tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is +parallel and non-blocking. From 21cfbb514c641bd14679cb1c297a8cc72759471c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 22:28:02 +0800 Subject: [PATCH 02/46] docs(adr): resolve MCP-over-ACP browser-control design (D1-D4) + flow diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the resolved design decisions into the browser-control ADR §7: - D1: auto-approve all browser tool permissions (core keeps auto-replying request_permission); fine-grained control deferred. Drops the dedicated request_permission-relay task; T1's server->client machinery stays (needed by the upstream MCP tunnel). - D2: inject the proxy via each agent's native MCP config (Cursor -> .cursor/mcp.json), not ACP session/new mcpServers (Cursor ignores those; cf. zed-industries/zed#50924). Content (HTTP url+headers) is portable; no universal config location exists. - D3: downstream (agent<->core) is a normal in-process Streamable-HTTP MCP server on loopback (via rmcp), NOT an on-ACP-stream tunnel (the ACP maintainer backed off on-stream MCP; cf. discussion #58). Upstream (core/gateway<->extension) is the one legitimate tunnel and adopts the official MCP-over-ACP RFD framing (mcp/connect + mcp/message); the RFD's "type":"acp" downstream injection is unused (Cursor unsupported). - D4: core's HTTP MCP server is always-on and decoupled from the extension WS, so the WS can attach after session start; core static-advertises the browser toolset and emits notifications/tools/list_changed on attach/detach. Also adds a TL;DR flow, an as-designed execution flow, a detailed message-level runtime sequence, and the T0 spike checklist; updates Findings/Tasks/Ownership accordingly (T3 dropped, T4 = RFD framing, T5 = HTTP MCP server + per-adapter config injection). Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 218 ++++++++++++++++--- 1 file changed, 192 insertions(+), 26 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 99169f6c3..9e4698749 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -65,6 +65,12 @@ The single `/acp` WS carries BOTH the ACP chat session (initialize / session.pro session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), distinguished by ACP method namespace. No second connection. +> **Refinement (see §7 "Design decisions"):** this multiplexing applies to the **upstream** +> hop (extension ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The +> **downstream** hop (core ↔ agent) is *not* tunnelled over ACP — core hosts a normal +> in-process HTTP MCP server the agent connects to. Only the extension, which cannot open a +> listening socket, needs MCP tunnelled over its `/acp` WS. + ## 3. Protocol gap to close first The base does only client→agent (prompt) and agent→client **notifications** (streaming @@ -112,28 +118,185 @@ path items is therefore done. What remains splits cleanly into an **OpenAB (serv and an **extension (client) side**, meeting at a single **MCP-over-ACP wire contract** (T4) so the two can proceed largely in parallel once that contract is fixed. +### TL;DR — how one browser action flows + +``` + [ LLM ] ────▶ [ OpenAB (core) ] ────▶ [ browser extension ] + wants to act middle-man / relay operates the real tab + ────────────────────────────────────────────────────────────────────── + inside the server pod in the user's browser (remote) + + Request 1. LLM decides "click the Submit button" + 2. OpenAB relays the action to the extension in the user's browser + 3. the extension actually clicks it in the active tab + Result 4. the extension reports "clicked; page went to /thanks" + 5. OpenAB hands the result back to the LLM + 6. the LLM continues → the user sees its narration in the side panel + + The LLM thinks it is calling an ordinary set of tools; in reality OpenAB is a + middle-man relaying every action to the real remote browser (and relaying the + tool list the other way). Only the OpenAB↔browser leg leaves the server; the + LLM↔OpenAB legs stay in-pod. The detailed message-level sequence is below. +``` + +### Design decisions (resolved 2026-07-19) + +Four decisions were worked through and locked; they refine §2 and the tasks below. + +- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core + keeps auto-replying `session/request_permission` with OK (existing + `connection.rs` behaviour); fine-grained control is deferred. Consequence: a dedicated + `request_permission`-relay task (was T3) is **dropped**, but T1's server→client request + machinery is still required — the **upstream MCP tunnel** needs it. + +- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` + parameter is **not** reliable for this: Cursor's CLI ignores ACP-passed MCP servers and + only loads MCP from its **own config** (`.cursor/mcp.json`) — see + [zed-industries/zed#50924](https://github.com/zed-industries/zed/issues/50924). So the + proxy is registered **per-agent, in that agent's native MCP config** (Cursor → + `.cursor/mcp.json`; others via their own file/format — there is no universal location: + VS Code uses the `servers` key, Codex uses TOML). The **content** (an HTTP MCP entry: + `url` + `headers`) is portable across vendors, so "as long as it loads, we're fine". + +- **D3 — where MCP is tunnelled.** **Downstream (agent ↔ core) is a *normal* MCP server, + not an on-ACP-stream tunnel.** The ACP maintainer prototyped on-stream MCP-over-ACP and + backed off — agents already connect to MCP servers well, and a special on-stream MCP type + is invasive + ([discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). So core + hosts a **Streamable-HTTP MCP server in-process** on `127.0.0.1:` (loopback + bearer, + via `rmcp`); the agent connects to it like any other MCP server. The **upstream** + (core/gateway ↔ extension) is the one legitimate tunnel (an MV3 extension cannot listen), + and it adopts the **official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp)** + framing (`mcp/connect` → `connectionId`, then `mcp/message`), *not* a hand-rolled envelope. + The RFD's own `"type":"acp"` downstream-injection path is **not** used (Cursor doesn't + support it; see D2). + +- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is + **always-on and decoupled** from the extension WS; the extension connecting/disconnecting + only changes *backend availability*. To let browser tools appear on a session whose WS + attaches late (reconnect, or attach-to-running-session), core does **both**: (a) **static- + advertise** the fixed browser toolset regardless of WS state — a `tools/call` while no + extension is attached returns an MCP error ("browser not connected"), which decouples WS + timing from session start with no client dependency; **and** (b) emit + `notifications/tools/list_changed` when the extension attaches/detaches, so agents that + re-query pick up extension-defined extras and fresh schema. + +### Execution flow (as designed) + +``` +Legend = ACP (JSON-RPC over stdio) - HTTP MCP (loopback) <=> /acp WS (only hop off-pod) + [C] = MCP client [S] = MCP server + + OPENAB POD (`openab run`) REMOTE (user browser) + ┌───────────┐ ┌──────────────────────────────────┐ ┌────────────────┐ + │ agent CLI │===│ gateway core │ │ browser ext. │ + │ (Cursor) │ │ /acp srv MCP proxy + │ │ (katashiro) │ + │ LLM [C] │-┐ │ HTTP MCP srv :PORT │<==WS==> │ [S] browser │ + └───────────┘ │ └──────────────────────────────────┘ └────────────────┘ + └── http 127.0.0.1:PORT ──┘ + +Bootstrap + B1 core starts in-process HTTP MCP server @127.0.0.1:PORT (loopback + bearer) + B2 core [per-agent adapter] writes the HTTP MCP entry into the agent's native config + (Cursor → .cursor/mcp.json) BEFORE the agent boots + B3 extension opens /acp WS <=> gateway; ACP initialize; session/new declares its browser + MCP server ("type":"acp") + B4 gateway --mcp/connect--> extension → connectionId (upstream tunnel established) + B5 agent boots, reads config → HTTP-connects to core's MCP server → MCP initialize + +Discovery (tools/list) + D1 agent --http tools/list--> core proxy + D2 core --mcp/message: tools/list--> gateway <=> extension (or served from static set, D4) + D3 extension returns [click, read_dom, navigate, type, screenshot] + D4 core returns the list --http--> agent → the LLM now sees browser tools + +Runtime (LLM clicks a button) + 1 LLM decides browser.click{selector} + 2 agent ==session/request_permission==> core → core auto-approves (D1) ==OK==> agent + 3 agent --http tools/call browser.click--> core proxy [in-pod] + 4 core --mcp/message: tools/call--> gateway + 5 gateway ==server→client request==> <=> extension (leaves pod) + 6 extension runs chrome.scripting click in the active tab + 7 extension ==result==> <=> gateway (back in pod) + 8 gateway → core (match pending id) --http result--> agent → LLM continues + +Only steps 5/7 leave the pod. Outer tunnel ids are paired by the gateway pending-map; the +inner MCP ids are the MCP layer's own bookkeeping and are never inspected by the gateway. +``` + +### Runtime sequence (detailed) — one `browser.click` round-trip + +``` +Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) + G = gateway (/acp WS srv) E = extension (MCP server, browser) + +Transports --ACP--> downstream ACP over stdio (chat / permission) + --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) + ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) + +Precondition: session open, extension WS attached, tools/list already discovered +-------------------------------------------------------------------------------- + 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 + 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 + .............................................................................. + 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 + 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 + payload=[ mcp#7 tools/call ] id=acp#55 + 5 G ==WS===> E server->client request (T1) = MCP-over-ACP outer id=acp#55 <-off-pod + 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks + 7 G <==WS== E response payload=[ mcp#7 result:{ok,url:"/thanks"} ] outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> extracts inner mcp#7 + 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 + .............................................................................. +10 A LLM consumes the tool result, keeps reasoning +11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) +12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod +-------------------------------------------------------------------------------- +Two id spaces (never mixed) + - mcp#7 = MCP-layer id, carried verbatim agent<->core<->extension (steps 3->5->7->9) + - acp#55 = outer ACP-envelope id on the upstream tunnel; only the gateway pending-map + tracks it (steps 4<->8) + - acp#1 = downstream ACP permission id; unrelated to the two above + +Only steps 5/7/12 leave the pod (all on the /acp WS). Permission (1-2) and tool transport +(3, 9) stay in-pod on loopback. If the extension is not attached at step 5, core returns an +MCP error "browser not connected" (D4 static-advertise: calls fail gracefully, no crash). +``` + +### T0 spike checklist (what the live PoC must confirm) + +1. Cursor loads an **HTTP** MCP server registered in `.cursor/mcp.json` (auto, or needs a + one-time `cursor-agent mcp enable`). +2. Cursor honours `notifications/tools/list_changed` and re-fetches mid-session (validates + D4(b); if not, D4(a) static-advertise carries it). +3. Cursor handles a `tools/call` error ("browser not connected") gracefully. + ### Findings that reshape the work - The **agent→client REQUEST direction already exists on the downstream hop**: `openab-core/src/acp/connection.rs` receives `session/request_permission` from the agent and currently **auto-replies** it (~L252). So T1 is not green-field — it is *relaying* those downstream requests up to the `/acp` client (and the response back) instead of auto-answering them. -- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784). - Giving the agent browser tools = **injecting a core-side proxy MCP server** into that list - (T5) that tunnels `tools/*` to the extension. +- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784), + but that path is **not** how the agent gets the browser tools (see D2 — Cursor ignores + ACP-passed MCP servers). Giving the agent browser tools = core **hosts a proxy HTTP MCP + server** and registers it in the agent's **native** MCP config (T5); the proxy tunnels + `tools/*` to the extension over the upstream MCP-over-ACP link. ### Ownership - **OpenAB side** (`feat/acp-mcp-browser`): T1, T2, T4 (contract + core routing), T5. -- **Extension side** (katashiro): T6; plus the client halves of T3 (respond to - `request_permission`) and T4 (serve MCP over the tunnel). +- **Extension side** (katashiro): T6; plus the client half of T4 (serve MCP over the + tunnel). (Permission is auto-approved by core per D1, so no consent UX is needed yet.) - **Both**: T7. ### Tasks -**T0 — Spike (do first; de-risks everything).** PoC: give `cursor-agent` a non-empty -`mcpServers` pointing at a mock MCP server and confirm the LLM actually discovers -(`tools/list`) and calls (`tools/call`) a tool; confirm a downstream agent→client request -can be relayed and answered. If this doesn't hold, the browser goal needs a different path. +**T0 — Spike (do first; de-risks everything).** PoC per the **T0 spike checklist** above: +register a mock **HTTP** MCP server in Cursor's `.cursor/mcp.json` and confirm the LLM +discovers (`tools/list`) and calls (`tools/call`) a tool, honours `tools/list_changed`, and +handles a call error gracefully. If this doesn't hold, the browser goal needs a different +path. (The agent→client request direction it depends on already exists downstream — see +Findings.) **T1 — agent→client REQUEST direction (relay).** - 1.1 Decide to relay downstream requests (`request_permission`, later MCP) to the `/acp` @@ -151,22 +314,25 @@ can be relayed and answered. If this doesn't hold, the browser goal needs a diff migration). 2.2 Type the new bidirectional messages (`request_permission`, MCP tunnel). 2.3 Round-trip validate against real traffic (ACP trace mode). -**T3 — `session/request_permission` end-to-end.** Largely the first concrete case of T1 -(relay the request, extension consent UX, relay the response) — folds into T1, not a -separate large task. +**T3 — `session/request_permission`.** **Dropped** per D1 (auto-approve; core keeps +auto-replying). Fine-grained permission control is a later, separate effort. -**T4 — MCP-over-ACP tunnel framing.** -- 4.1 Fix the **wire contract**: how MCP JSON-RPC (`tools/list` / `tools/call` / results) - is multiplexed over `/acp` (method namespace, e.g. `_mcp/*`; request/response - correlation; framing). 4.2 Gateway routes MCP-namespaced messages between the `/acp` - client and core. 4.3 Contract doc (the spec the extension implements). 4.4 Mock-MCP- - client-over-tunnel tests. +**T4 — MCP-over-ACP tunnel framing (upstream only).** Adopt the official +[MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) rather than a +hand-rolled envelope (D3). +- 4.1 `mcp/connect` (→ `connectionId`) + `mcp/message` (carries the inner MCP JSON-RPC; + outer ACP id ↔ pending-map, inner MCP id opaque) + `mcp/disconnect`. 4.2 Gateway routes + these between the `/acp` client (extension) and core. 4.3 Contract doc (the spec the + extension implements). 4.4 Mock-MCP-over-tunnel tests. **T5 — OpenAB core = MCP proxy/aggregator.** -- 5.1 A core-side local MCP server (proxy) the agent connects to via `mcpServers`. -- 5.2 Inject that proxy into the downstream `mcpServers` (currently `[]`). -- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel. -- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent). +- 5.1 A core-side **Streamable-HTTP MCP server hosted in-process** on `127.0.0.1:` + (loopback + bearer) that the agent connects to (D3). +- 5.2 **Per-agent adapter** registers that server in the agent's native MCP config (Cursor → + `.cursor/mcp.json`) before boot, *not* via ACP `session/new mcpServers` (D2). +- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel (T4). +- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent); static- + advertise the browser toolset + emit `tools/list_changed` on attach/detach (D4). - 5.5 `rmcp` wiring (already used by `openab-agent`) + tests. **T6 — extension = MCP server + browser tools** (katashiro). @@ -179,10 +345,10 @@ separate large task. **T7 — integration + e2e + deploy.** - 7.1 Full loop: `tools/list` → LLM calls `browser.click` → extension executes → result → LLM continues. 7.2 A browser-loop e2e (extend `scripts/acp-ws-smoke.py`). - 7.3 Rebuild + redeploy Falcon. 7.4 Finalize this ADR. + 7.3 Rebuild + redeploy the deployed Cursor agent. 7.4 Finalize this ADR. ### Suggested order -T0 spike → T1 (+ T3 as its first case) → T2 → **T4 (fix the wire contract)** → T5 → then -T6 in parallel against the contract → T7. The heavy items are T1 (the direction), T4/T5 -(tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is +T0 spike → T1 (server→client request direction) → T2 → **T4 (adopt the RFD framing)** → T5 +→ then T6 in parallel against the contract → T7. The heavy items are T1 (the direction), +T4/T5 (tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is parallel and non-blocking. From b14552ae0d3da569c265cf49858808f14e80bca2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 22:50:59 +0800 Subject: [PATCH 03/46] feat(gateway/acp): server-initiated request direction (T1.2/1.3) Add the agent->client REQUEST direction to the ACP WebSocket server, the plumbing the MCP-over-ACP tunnel needs. The base only had client->server requests plus server->client notifications; this adds: - route_client_response(): the read loop now recognises an inbound client *response* (id present, no `method`, carries result/error) and routes it to the waiting request via a per-connection pending map, instead of answering it with -32600. Gated on !is_notification so notification/request handling is untouched. - pending_requests: Arc>>> per connection, drained on disconnect so in-flight awaiters unblock with "connection closed" rather than hanging until timeout. - send_request() + JsonRpcRequestOut: mint an id, register the oneshot, send the frame over the existing outbound channel, timeout-await the correlated response. Landed with #[allow(dead_code)] as ready infrastructure; its caller arrives with T1.4 (the core<->gateway bridge). Mirrors the existing client-side pattern in openab-core/src/acp/connection.rs. Adds an acp_requests test module (route + send_request round-trip, id minting, request/notification rejection, unmatched id). Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 189 +++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 18c2b2cca..e6965bf80 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -21,8 +21,9 @@ use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -324,6 +325,19 @@ struct JsonRpcNotification { params: Value, } +/// A SERVER-INITIATED JSON-RPC request (the agent→client REQUEST direction, T1). The base +/// only ever *received* requests, so `JsonRpcRequest` is deserialize-only; this is the +/// outbound counterpart used by `send_request`. Wired to a caller by T1.4 (core↔gateway +/// bridge) / the MCP-over-ACP tunnel; landed ahead of its caller as ready infrastructure. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct JsonRpcRequestOut { + jsonrpc: &'static str, + id: u64, + method: String, + params: Value, +} + impl JsonRpcResponse { fn success(id: Value, result: Value) -> Self { Self { @@ -406,6 +420,67 @@ pub async fn ws_upgrade( // ACP Connection handler // --------------------------------------------------------------------------- +/// Route an inbound client *response* (an id-bearing frame with `result`/`error` and NO +/// `method`) to the `send_request` awaiter registered under its id. Returns `true` when the +/// frame was a response we consumed (so the caller must stop dispatching it as a request). +/// Mirrors the client-side correlation in `openab-core/src/acp/connection.rs`. +async fn route_client_response( + pending: &Arc>>>, + raw: &Value, +) -> bool { + let has_method = raw.get("method").is_some(); + let looks_like_response = raw.get("result").is_some() || raw.get("error").is_some(); + if has_method || !looks_like_response { + return false; + } + let Some(id) = raw.get("id").and_then(Value::as_u64) else { + return false; + }; + if let Some(tx) = pending.lock().await.remove(&id) { + let _ = tx.send(raw.clone()); + } else { + warn!(id, "acp: client response with no matching pending request"); + } + true +} + +/// Send a server-initiated JSON-RPC request to the connected ACP client and await its +/// response (the agent→client REQUEST direction, T1). Mints an id, registers a oneshot in +/// `pending`, writes the frame via the outbound channel, then awaits the correlated response +/// (resolved by `route_client_response`) with a timeout. Wired to a caller by T1.4 (the +/// core↔gateway MCP-over-ACP bridge); landed ahead of its caller as ready infrastructure. +#[allow(dead_code)] +async fn send_request( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + method: impl Into, + params: Value, + timeout_secs: u64, +) -> Result { + let id = next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(id, tx); + let frame = JsonRpcRequestOut { + jsonrpc: "2.0", + id, + method: method.into(), + params, + }; + if out_tx.send(serde_json::to_string(&frame).unwrap()).is_err() { + pending.lock().await.remove(&id); + return Err("connection closed".into()); + } + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await { + Ok(Ok(v)) => Ok(v), + Ok(Err(_)) => Err("connection closed before response".into()), + Err(_) => { + pending.lock().await.remove(&id); + Err("request timed out".into()) + } + } +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -418,6 +493,12 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // Session state for this connection let sessions: Arc>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + // Pending server-initiated requests (T1): id → oneshot for the client's response. The + // base had only client→server requests + server→client notifications; the MCP-over-ACP + // tunnel adds the server→client REQUEST direction, correlated through this map by + // `route_client_response` (inbound) and `send_request` (outbound, wired in T1.4). + let pending_requests: Arc>>> = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); let mut initialized = false; // Track spawned prompt tasks so we can abort on disconnect @@ -494,6 +575,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } + // A client *response* to a server-initiated request (T1): id present, no `method`, + // carries `result`/`error`. Route it to the waiting `send_request` and stop — it is + // neither a client request nor a notification. Gated on `!is_notification` (a + // notification never carries an id, so it can never be a response), keeping the + // existing notification/request handling below untouched. + if !is_notification && route_client_response(&pending_requests, &raw).await { + continue; + } + let req: JsonRpcRequest = match serde_json::from_value(raw) { Ok(r) => r, Err(e) => { @@ -699,6 +789,13 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { prompt_tasks.retain(|h| !h.is_finished()); } + // Drain any in-flight server-initiated requests: dropping each oneshot sender makes the + // corresponding `send_request` awaiter resolve to "connection closed before response" + // rather than hang until timeout. Mirrors the close-drain in openab-core connection.rs. + for (_id, tx) in pending_requests.lock().await.drain() { + drop(tx); + } + // --- Disconnect cleanup --- // Abort any in-flight prompt tasks to prevent registry leaks for handle in prompt_tasks { @@ -1585,6 +1682,96 @@ mod acp_streaming { // Handler-level tests — call the real handlers (not just literal round-trips) and // assert their actual output + side effects. // --------------------------------------------------------------------------- +#[cfg(test)] +mod acp_requests { + //! T1 — the agent→client REQUEST direction: server-initiated `send_request` (mints an + //! id, awaits the correlated response) and inbound `route_client_response`. + use super::{route_client_response, send_request}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + use tokio::sync::{mpsc, oneshot}; + + fn new_pending( + ) -> Arc>>> { + Arc::new(tokio::sync::Mutex::new(HashMap::new())) + } + + #[tokio::test] + async fn route_client_response_resolves_pending() { + let pending = new_pending(); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(5, tx); + let consumed = + route_client_response(&pending, &json!({"jsonrpc":"2.0","id":5,"result":{"ok":true}})) + .await; + assert!(consumed, "an id+result frame is a response we consume"); + assert_eq!(rx.await.unwrap()["result"]["ok"], json!(true)); + assert!( + pending.lock().await.is_empty(), + "the pending entry must be removed" + ); + } + + #[tokio::test] + async fn route_client_response_ignores_requests_and_notifications() { + let pending = new_pending(); + // has `method` → a request, not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":1,"method":"foo"})).await); + // notification-shaped, no result/error → not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","method":"bar"})).await); + // id present but neither result nor error → not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":2})).await); + } + + #[tokio::test] + async fn route_client_response_unknown_id_consumes_without_panic() { + let pending = new_pending(); + let consumed = route_client_response( + &pending, + &json!({"jsonrpc":"2.0","id":99,"error":{"code":-1,"message":"x"}}), + ) + .await; + assert!(consumed, "an unmatched response is still consumed (logged, no panic)"); + } + + #[tokio::test] + async fn send_request_mints_incrementing_ids_and_returns_response() { + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Driver: read the emitted request frame, assert its shape, feed a matching response. + let pending2 = pending.clone(); + let driver = tokio::spawn(async move { + let frame = out_rx.recv().await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["jsonrpc"], json!("2.0")); + assert_eq!(v["id"], json!(1)); + assert_eq!(v["method"], json!("mcp/message")); + assert_eq!(v["params"]["connectionId"], json!("conn-1")); + let id = v["id"].as_u64().unwrap(); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":id,"result":{"pong":true}})) + .await; + }); + + let resp = send_request( + &out_tx, + &pending, + &next_id, + "mcp/message", + json!({"connectionId":"conn-1"}), + 5, + ) + .await + .unwrap(); + assert_eq!(resp["result"]["pong"], json!(true)); + driver.await.unwrap(); + assert_eq!(next_id.load(Ordering::Relaxed), 2, "the id counter advanced"); + } +} + #[cfg(test)] mod acp_handlers { use super::{ From 4d6055d3707eb59f3dd50da5464570ea1d7771b2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 23:09:46 +0800 Subject: [PATCH 04/46] refactor(gateway/acp): construct trivial responses from generated types (T2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the three trivial outbound response payloads from hand-rolled `json!` to the generated `acp_schema` types, so the wire shape is type-checked: - session/new → NewSessionResponse { session_id: SessionId(..) } - session/resume → ResumeSessionResponse::default() (serializes to {}) - prompt final → PromptResponse { stop_reason }, with `stop_reason` now a typed StopReason enum (EndTurn / Cancelled) instead of a &str literal. rename + skip_serializing_if make the emitted wire byte-identical to the prior `json!`, so the existing conforms:: round-trip tests and handler behaviour tests are unchanged (292 pass). handle_initialize stays hand-rolled for now (nested agentCapabilities/agentInfo; low value, per base ADR §7 the trivial chat subset does not require typed construction). The remaining T2.2 (typing the mcp/connect + mcp/message bidirectional frames) lands with T4. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index e6965bf80..c76db00b1 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -902,8 +902,16 @@ async fn handle_session_new( // Downgraded from info! — sessionId is a resume capability; keep it out of normal logs (F12). debug!(session = %session_id, "ACP session created"); - // ACP session/new response is just { sessionId }. - JsonRpcResponse::success(id, json!({ "sessionId": session_id })) + // ACP session/new response is just { sessionId }. Constructed from the generated + // NewSessionResponse (T2.1) so the wire shape is type-checked against acp_schema; the + // optional fields skip-serialize, giving the same { "sessionId": ... } wire. + let resp = crate::adapters::acp_schema::NewSessionResponse { + session_id: crate::adapters::acp_schema::SessionId(session_id), + config_options: None, + meta: None, + modes: None, + }; + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) } /// `session/resume` — re-attach to a session the client persisted, WITHOUT @@ -977,8 +985,10 @@ async fn handle_session_resume( debug!(session = %session_id, "ACP session resumed"); - // ACP session/resume response is an empty object (no history replay). - JsonRpcResponse::success(id, json!({})) + // ACP session/resume response is an empty object (no history replay) — the generated + // ResumeSessionResponse default serializes to {} (T2.1, type-checked against acp_schema). + let resp = crate::adapters::acp_schema::ResumeSessionResponse::default(); + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) } /// Handle `session/cancel`. Per ACP it is a one-way NOTIFICATION: the notification form @@ -1131,14 +1141,15 @@ async fn handle_session_prompt( // Stream replies back as ACP `session/update` notifications. let mut sent_len = 0usize; let timeout = tokio::time::Duration::from_secs(180); - let mut stop_reason = "end_turn"; + // Typed StopReason (T2.1) so the final PromptResponse is constructed from acp_schema. + let mut stop_reason = crate::adapters::acp_schema::StopReason::EndTurn; let mut timed_out = false; loop { tokio::select! { // session/cancel fired — stop gracefully. _ = cancel.notified() => { - stop_reason = "cancelled"; + stop_reason = crate::adapters::acp_schema::StopReason::Cancelled; break; } recv = tokio::time::timeout(timeout, reply_rx.recv()) => { @@ -1196,7 +1207,12 @@ async fn handle_session_prompt( let resp = if timed_out { JsonRpcResponse::error(id, -32603, "Timed out waiting for agent backend") } else { - JsonRpcResponse::success(id, json!({ "stopReason": stop_reason })) + // T2.1: construct the typed PromptResponse; serializes to { "stopReason": ... }. + let pr = crate::adapters::acp_schema::PromptResponse { + stop_reason, + meta: None, + }; + JsonRpcResponse::success(id, serde_json::to_value(&pr).unwrap()) }; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); } From 54ac76198facc7828c3a050bf698b9851613d587 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 23:23:18 +0800 Subject: [PATCH 05/46] feat(gateway/acp): MCP-over-ACP tunnel frame API (T4.1) Add the gateway-side helpers for the official MCP-over-ACP RFD tunnel (agentclientprotocol.com/rfds/mcp-over-acp), built on the T1 send_request machinery (its first real callers): - mcp_connect(acpId) -> connectionId - mcp_message_request(connectionId, method, params) -> inner MCP result - mcp_disconnect(connectionId) - McpConnectParams / McpConnectResult / McpMessageParams / McpDisconnectParams (hand-rolled: these RFC methods are not in the generated acp_schema, which only has the session/new McpServer* declaration types + McpCapabilities), plus frame_result() to unwrap a response frame's result / surface its error. Per the RFD, mcp/message flattens the inner MCP method/params into the params object WITHOUT the inner MCP id; correlation is purely by the outer ACP id, and the response result is the inner MCP result payload. Adds a mock-tunnel round-trip test (mcp/connect -> connectionId, mcp/message tools/list -> result). Helpers carry #[allow(dead_code)] until T5 wires them to the core MCP proxy (their real caller). Remaining T4: session/new "type":"acp" parsing + advertise mcpCapabilities.acp, gateway<->core routing (with T5), contract doc. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index c76db00b1..3cf923fc4 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -338,6 +338,52 @@ struct JsonRpcRequestOut { params: Value, } +// --- MCP-over-ACP tunnel frames (T4) ------------------------------------------ +// The official MCP-over-ACP RFD (agentclientprotocol.com/rfds/mcp-over-acp) tunnels MCP +// over the /acp WS with three methods: mcp/connect → connectionId, then mcp/message (the +// inner MCP method/params FLATTENED into the params, correlated by the OUTER ACP id — the +// inner MCP id is not carried), and mcp/disconnect. These types are NOT in the generated +// `acp_schema` (the RFD is a proposal, not the stable v1 schema), so they are hand-rolled +// here. The extension is the MCP server and assigns `connectionId`; the gateway (agent side +// of the upstream hop) is the connector and issues connect/message/disconnect. + +/// `mcp/connect` params — `acpId` matches the `id` of the client's `session/new` +/// `mcpServers` entry with `"type":"acp"`. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpConnectParams { + #[serde(rename = "acpId")] + acp_id: String, +} + +/// `mcp/connect` result — the client-assigned connection handle. +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct McpConnectResult { + #[serde(rename = "connectionId")] + connection_id: String, +} + +/// `mcp/message` params — the inner MCP `method`/`params` are flattened in (no inner id); +/// `connectionId` selects the tunnelled MCP connection. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpMessageParams { + #[serde(rename = "connectionId")] + connection_id: String, + method: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, +} + +/// `mcp/disconnect` params. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpDisconnectParams { + #[serde(rename = "connectionId")] + connection_id: String, +} + impl JsonRpcResponse { fn success(id: Value, result: Value) -> Self { Self { @@ -481,6 +527,77 @@ async fn send_request( } } +/// Extract the `result` from a JSON-RPC response frame, mapping an `error` member to `Err`. +/// `send_request` yields the whole response frame; the tunnel helpers want just the payload. +#[allow(dead_code)] +fn frame_result(frame: Value) -> Result { + if let Some(err) = frame.get("error") { + return Err(format!("remote error: {err}")); + } + Ok(frame.get("result").cloned().unwrap_or(Value::Null)) +} + +/// `mcp/connect` (T4): open a tunnelled MCP connection to the client-provided (`"type":"acp"`) +/// MCP server identified by `acp_id`; returns the client-assigned `connectionId`. +#[allow(dead_code)] +async fn mcp_connect( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + acp_id: &str, + timeout_secs: u64, +) -> Result { + let params = serde_json::to_value(McpConnectParams { + acp_id: acp_id.to_string(), + }) + .unwrap(); + let frame = send_request(out_tx, pending, next_id, "mcp/connect", params, timeout_secs).await?; + let result: McpConnectResult = serde_json::from_value(frame_result(frame)?) + .map_err(|e| format!("mcp/connect: malformed result: {e}"))?; + Ok(result.connection_id) +} + +/// `mcp/message` REQUEST (T4): tunnel an inner MCP request over `connection_id`; returns the +/// inner MCP result payload (the outer ACP id does the correlation; the inner MCP id is not +/// carried on the wire). +#[allow(dead_code)] +async fn mcp_message_request( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + method: &str, + params: Option, + timeout_secs: u64, +) -> Result { + let msg = serde_json::to_value(McpMessageParams { + connection_id: connection_id.to_string(), + method: method.to_string(), + params, + }) + .unwrap(); + let frame = send_request(out_tx, pending, next_id, "mcp/message", msg, timeout_secs).await?; + frame_result(frame) +} + +/// `mcp/disconnect` (T4): close a tunnelled MCP connection. +#[allow(dead_code)] +async fn mcp_disconnect( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + timeout_secs: u64, +) -> Result<(), String> { + let params = serde_json::to_value(McpDisconnectParams { + connection_id: connection_id.to_string(), + }) + .unwrap(); + send_request(out_tx, pending, next_id, "mcp/disconnect", params, timeout_secs) + .await + .map(|_| ()) +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -1702,7 +1819,7 @@ mod acp_streaming { mod acp_requests { //! T1 — the agent→client REQUEST direction: server-initiated `send_request` (mints an //! id, awaits the correlated response) and inbound `route_client_response`. - use super::{route_client_response, send_request}; + use super::{mcp_connect, mcp_message_request, route_client_response, send_request}; use serde_json::json; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -1786,6 +1903,47 @@ mod acp_requests { driver.await.unwrap(); assert_eq!(next_id.load(Ordering::Relaxed), 2, "the id counter advanced"); } + + #[tokio::test] + async fn mcp_tunnel_connect_and_message_roundtrip() { + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Mock extension: answer mcp/connect with a connectionId, then turn an mcp/message + // tools/list into an inner result, routing each reply by the frame's own outer id. + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f1: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f1["method"], json!("mcp/connect")); + assert_eq!(f1["params"]["acpId"], json!("srv-1")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f1["id"],"result":{"connectionId":"conn-9"}}), + ) + .await; + + let f2: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f2["method"], json!("mcp/message")); + assert_eq!(f2["params"]["connectionId"], json!("conn-9")); + assert_eq!(f2["params"]["method"], json!("tools/list")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f2["id"],"result":{"tools":[{"name":"browser.click"}]}}), + ) + .await; + }); + + let conn = mcp_connect(&out_tx, &pending, &next_id, "srv-1", 5) + .await + .unwrap(); + assert_eq!(conn, "conn-9"); + let result = mcp_message_request(&out_tx, &pending, &next_id, &conn, "tools/list", None, 5) + .await + .unwrap(); + assert_eq!(result["tools"][0]["name"], json!("browser.click")); + ext.await.unwrap(); + } } #[cfg(test)] From f6c92940814a81fb2f2e4e131e774a7be1e416ca Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 00:05:01 +0800 Subject: [PATCH 06/46] feat(core): scaffold MCP proxy server deps + static browser toolset (T5.1a) Introduce the core-hosted MCP proxy for MCP-over-ACP browser control (D3/D4), starting with the dependency integration + the static tool set: - openab-core gains optional rmcp (server + transport-streamable-http-server), axum 0.8 (matches the gateway, one axum in the workspace), and tokio-util, gated behind a new `acp-mcp` feature so non-acp builds don't pull them. The root `acp` feature (included by `unified`) now enables `openab-core/acp-mcp`. This is the workspace's first rmcp server-side usage; it resolves + compiles cleanly alongside openab-agent's rmcp client features. - New `mcp_proxy` module (feature-gated) with `browser_tools()`: the fixed DOM-semantic tool set (click / read_dom / navigate / type / screenshot) that, per D4, core static-advertises regardless of whether an extension is attached. Built from rmcp `Tool::new` + typed input schemas; unit-tested. `browser_tools()` carries #[allow(dead_code)] until the ServerHandler wires it. Next (T5.1b): ServerHandler impl + spawn_mcp_server (loopback + bearer axum listener); then T5.2 config injection and T5.3 tunnel wiring. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 207 +++++++++++++++++++++++++--- Cargo.toml | 2 +- crates/openab-core/Cargo.toml | 8 ++ crates/openab-core/src/lib.rs | 2 + crates/openab-core/src/mcp_proxy.rs | 100 ++++++++++++++ 5 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 crates/openab-core/src/mcp_proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 85341ca9b..970462b42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -593,7 +593,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -846,6 +846,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -911,7 +922,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1136,7 +1147,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1171,7 +1182,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1180,6 +1191,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1379,7 +1396,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1458,6 +1475,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -2221,6 +2239,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sigv4", + "axum", "base64", "bytes", "chrono", @@ -2238,6 +2257,7 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest", + "rmcp", "rpassword", "rustls 0.22.4", "serde", @@ -2249,6 +2269,7 @@ dependencies = [ "tokio", "tokio-rustls 0.25.0", "tokio-tungstenite 0.21.0", + "tokio-util", "toml", "toml_edit", "tracing", @@ -2340,6 +2361,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -2413,7 +2440,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2540,7 +2567,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2672,6 +2699,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2710,6 +2748,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2719,6 +2763,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + [[package]] name = "regex" version = "1.12.4" @@ -2821,6 +2885,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -2987,6 +3080,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3092,7 +3211,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -3322,6 +3452,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3351,6 +3494,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3368,7 +3522,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3420,7 +3574,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3431,7 +3585,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3523,7 +3677,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3557,6 +3711,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -3705,7 +3870,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3977,7 +4142,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -4068,7 +4233,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4079,7 +4244,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4340,7 +4505,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -4361,7 +4526,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4381,7 +4546,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -4421,7 +4586,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7e86b6178..282bca1b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ feishu = ["dep:openab-gateway", "dep:axum", "openab-gateway/feishu"] googlechat = ["dep:openab-gateway", "dep:axum", "openab-gateway/googlechat"] wecom = ["dep:openab-gateway", "dep:axum", "openab-gateway/wecom"] teams = ["dep:openab-gateway", "dep:axum", "openab-gateway/teams"] -acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp"] +acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp", "openab-core/acp-mcp"] [dev-dependencies] tempfile = "3.27.0" diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index 9d8b7389d..91c24f94b 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -49,6 +49,12 @@ aws-credential-types = { version = "1", optional = true } urlencoding = { version = "2", optional = true } hex = { version = "0.4", optional = true } http = { version = "1", optional = true } +# MCP proxy server (browser-control, feature `acp-mcp`): core hosts an in-process +# Streamable-HTTP MCP server the colocated agent connects to (D3). rmcp server + a +# self-owned loopback axum listener; kept optional so non-acp builds don't pull them. +rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true } +axum = { version = "0.8", optional = true } +tokio-util = { version = "0.7", optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -65,3 +71,5 @@ config-s3 = ["dep:aws-sdk-s3", "dep:aws-config"] pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate2", "dep:tar"] filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] +# Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). +acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util"] diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 7e50ce4ce..4e26a641d 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -1,5 +1,7 @@ pub mod acp; pub mod adapter; +#[cfg(feature = "acp-mcp")] +pub mod mcp_proxy; pub mod bot_turns; pub mod config; pub mod cron; diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs new file mode 100644 index 000000000..465a8f946 --- /dev/null +++ b/crates/openab-core/src/mcp_proxy.rs @@ -0,0 +1,100 @@ +//! Core-hosted MCP proxy server for MCP-over-ACP browser control (feature `acp-mcp`). +//! +//! Per ADR §7 (D3), OpenAB **core** hosts an in-process Streamable-HTTP MCP server on +//! loopback that the colocated agent CLI connects to as a normal MCP client. The server is a +//! proxy: its tool list + tool execution are backed by the remote browser extension over the +//! `/acp` MCP-over-ACP tunnel (wired in T5.3). Per D4 the browser tool set is +//! **static-advertised** regardless of whether an extension is currently attached — a call +//! while disconnected returns a "browser not connected" error rather than hiding the tools. +//! +//! This module currently provides the static tool set; the `ServerHandler` + loopback +//! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. + +use rmcp::model::{object, Tool}; +use serde_json::json; + +/// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- +/// semantic actions the extension executes in the user's active tab; model-agnostic. +/// Consumed by the `ServerHandler::list_tools` impl wired in the next T5 sub-tick. +#[allow(dead_code)] +pub(crate) fn browser_tools() -> Vec { + vec![ + Tool::new( + "browser.click", + "Click the element matching a CSS selector in the active browser tab.", + object(json!({ + "type": "object", + "properties": { "selector": { "type": "string", "description": "CSS selector" } }, + "required": ["selector"] + })), + ), + Tool::new( + "browser.read_dom", + "Read a snapshot of the active tab's DOM (optionally scoped to a selector).", + object(json!({ + "type": "object", + "properties": { "selector": { "type": "string", "description": "optional CSS selector to scope the snapshot" } } + })), + ), + Tool::new( + "browser.navigate", + "Navigate the active browser tab to a URL.", + object(json!({ + "type": "object", + "properties": { "url": { "type": "string", "description": "absolute URL" } }, + "required": ["url"] + })), + ), + Tool::new( + "browser.type", + "Type text into the element matching a CSS selector in the active tab.", + object(json!({ + "type": "object", + "properties": { + "selector": { "type": "string", "description": "CSS selector" }, + "text": { "type": "string", "description": "text to type" } + }, + "required": ["selector", "text"] + })), + ), + Tool::new( + "browser.screenshot", + "Capture a screenshot of the active browser tab.", + object(json!({ "type": "object", "properties": {} })), + ), + ] +} + +#[cfg(test)] +mod tests { + use super::browser_tools; + + #[test] + fn browser_tools_advertises_the_fixed_set() { + let tools = browser_tools(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + [ + "browser.click", + "browser.read_dom", + "browser.navigate", + "browser.type", + "browser.screenshot" + ] + ); + } + + #[test] + fn every_browser_tool_has_an_object_input_schema() { + for t in browser_tools() { + assert_eq!( + t.input_schema.get("type").and_then(|v| v.as_str()), + Some("object"), + "tool {} must have an object input schema", + t.name + ); + assert!(t.description.is_some(), "tool {} needs a description", t.name); + } + } +} From b8a01cb3eec404e842dbe335e2b7adb02d0747ce Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 00:29:37 +0800 Subject: [PATCH 07/46] feat(core): MCP proxy ServerHandler + loopback server (T5.1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up the core-hosted MCP server the colocated agent connects to (D3): - ProxyHandler impls rmcp ServerHandler: get_info advertises the tools capability; list_tools returns the static browser tool set (D4 static- advertise); call_tool returns "browser not connected" until the tunnel is wired (T5.3) — failing gracefully rather than hiding the tools (D4). - spawn_mcp_server binds an OS-assigned 127.0.0.1 port with its own axum listener (StreamableHttpService, stateless + JSON responses), graceful shutdown via a CancellationToken. The caller hands the port to the agent's native MCP config in T5.2. An HTTP integration test spawns the server, confirms it binds loopback, and that an MCP initialize returns a result advertising the tools capability. Bearer auth on the listener is added in T5.2 (the token is minted alongside the .cursor/mcp.json injection). Tunnel wiring (RemoteExtensionChannel -> mcp_connect /mcp_message) is T5.3. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 111 +++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 465a8f946..f39ed8059 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -10,13 +10,19 @@ //! This module currently provides the static tool set; the `ServerHandler` + loopback //! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. -use rmcp::model::{object, Tool}; +use rmcp::model::{ + object, CallToolRequestParams, CallToolResult, ErrorData as McpError, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::ServerHandler; use serde_json::json; /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. -/// Consumed by the `ServerHandler::list_tools` impl wired in the next T5 sub-tick. -#[allow(dead_code)] pub(crate) fn browser_tools() -> Vec { vec![ Tool::new( @@ -65,9 +71,78 @@ pub(crate) fn browser_tools() -> Vec { ] } +/// The core-hosted MCP server the colocated agent connects to (D3). A proxy: it advertises +/// the browser tools and (once T5.3 wires the tunnel) forwards `tools/call` to the extension +/// over MCP-over-ACP. Until then it static-advertises (D4) and returns "browser not +/// connected" on call. +#[derive(Clone, Default)] +pub struct ProxyHandler {} + +impl ServerHandler for ProxyHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "OpenAB browser-control proxy: DOM-semantic tools executed in the user's browser \ + via MCP-over-ACP.", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + // D4 static-advertise: expose the browser tools regardless of extension state. + Ok(ListToolsResult { + tools: browser_tools(), + ..Default::default() + }) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + // No extension wired yet (T5.3). Per D4, fail gracefully rather than hide the tool. + Err(McpError::internal_error( + "browser not connected: open the OpenAB side panel in your browser", + None, + )) + } +} + +/// Start the in-process Streamable-HTTP MCP proxy server on an OS-assigned **loopback** port +/// (D3). Returns the bound address; the caller hands `addr.port()` to the colocated agent's +/// native MCP config (T5.2). Shuts down when `ct` is cancelled. A bearer gate is added in +/// T5.2 (the token is minted alongside the config injection). +pub async fn spawn_mcp_server( + ct: tokio_util::sync::CancellationToken, +) -> std::io::Result { + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(ProxyHandler::default()), + Default::default(), + config, + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + }); + Ok(addr) +} + #[cfg(test)] mod tests { - use super::browser_tools; + use super::{browser_tools, spawn_mcp_server}; #[test] fn browser_tools_advertises_the_fixed_set() { @@ -97,4 +172,32 @@ mod tests { assert!(t.description.is_some(), "tool {} needs a description", t.name); } } + + #[tokio::test] + async fn mcp_server_binds_loopback_and_initializes() { + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server(ct.clone()).await.unwrap(); + assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); + + let url = format!("http://{addr}/mcp"); + let resp = reqwest::Client::new() + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#, + ) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["jsonrpc"], "2.0"); + assert!(body["result"].is_object(), "initialize must return a result"); + assert!( + body["result"]["capabilities"]["tools"].is_object(), + "server must advertise the tools capability" + ); + ct.cancel(); + } } From cc9dbf7faf8a23bf31d39993dab2bb3b1c8d2e03 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 00:49:08 +0800 Subject: [PATCH 08/46] feat(core): bearer gate on the MCP proxy server (T5.2 part 1) Even bound to loopback, the core MCP server now requires the token the agent's MCP config carries (D3), so another local process on the host can't reach the browser tools. spawn_mcp_server takes a `bearer` and layers an axum middleware that returns 401 when Authorization: Bearer is absent or wrong; the caller mints the token and shares it with the agent config. Tests: authed initialize -> 200 + tools capability; missing / wrong token -> 401. Remaining T5.2: the per-agent adapter writes { url: 127.0.0.1:, headers: Authorization Bearer } into the agent's native MCP config (Cursor -> .cursor/mcp.json) before boot, and wires spawn_mcp_server into openab startup. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 85 ++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index f39ed8059..ec537eedc 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -19,7 +19,9 @@ use rmcp::transport::streamable_http_server::{ session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, }; use rmcp::ServerHandler; +use axum::response::IntoResponse; use serde_json::json; +use std::sync::Arc; /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. @@ -111,11 +113,34 @@ impl ServerHandler for ProxyHandler { } } +/// Loopback bearer gate for the MCP server (D3): even bound to 127.0.0.1, require the token +/// the agent's MCP config carries, so another local process on the host can't reach the +/// browser tools. Returns 401 when the `Authorization: Bearer ` header is absent or +/// wrong. +async fn require_bearer( + axum::extract::State(expected): axum::extract::State>, + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let authed = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .is_some_and(|t| t == &*expected); + if authed { + next.run(req).await + } else { + axum::http::StatusCode::UNAUTHORIZED.into_response() + } +} + /// Start the in-process Streamable-HTTP MCP proxy server on an OS-assigned **loopback** port -/// (D3). Returns the bound address; the caller hands `addr.port()` to the colocated agent's -/// native MCP config (T5.2). Shuts down when `ct` is cancelled. A bearer gate is added in -/// T5.2 (the token is minted alongside the config injection). +/// (D3), gated by `bearer`. Returns the bound address; the caller hands `addr.port()` + the +/// same `bearer` to the colocated agent's native MCP config (T5.2). Shuts down when `ct` is +/// cancelled. pub async fn spawn_mcp_server( + bearer: String, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result { let config = StreamableHttpServerConfig::default() @@ -129,7 +154,12 @@ pub async fn spawn_mcp_server( Default::default(), config, ); - let router = axum::Router::new().nest_service("/mcp", service); + let router = axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn_with_state( + Arc::::from(bearer), + require_bearer, + )); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; tokio::spawn(async move { @@ -173,20 +203,23 @@ mod tests { } } + const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#; + #[tokio::test] - async fn mcp_server_binds_loopback_and_initializes() { + async fn mcp_server_binds_loopback_and_initializes_with_bearer() { let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server(ct.clone()).await.unwrap(); + let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + .await + .unwrap(); assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); let url = format!("http://{addr}/mcp"); let resp = reqwest::Client::new() .post(&url) + .header("Authorization", "Bearer secret-token") .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") - .body( - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#, - ) + .body(INIT_BODY) .send() .await .unwrap(); @@ -200,4 +233,38 @@ mod tests { ); ct.cancel(); } + + #[tokio::test] + async fn mcp_server_rejects_missing_or_wrong_bearer() { + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + .await + .unwrap(); + let url = format!("http://{addr}/mcp"); + let client = reqwest::Client::new(); + + // no Authorization header -> 401 + let no_auth = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(no_auth.status(), 401); + + // wrong token -> 401 + let wrong = client + .post(&url) + .header("Authorization", "Bearer nope") + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(wrong.status(), 401); + ct.cancel(); + } } From 63d65661cebaebbc992c2d6b65199269d96aa196 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 01:16:31 +0800 Subject: [PATCH 09/46] =?UTF-8?q?docs(adr):=20correct=20runtime=20diagram?= =?UTF-8?q?=20=E2=80=94=20mcp/message=20flattens,=20no=20inner=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP-over-ACP RFD flattens the inner MCP method/params into the mcp/message params and does NOT carry an inner MCP id; correlation on the upstream tunnel is by the outer ACP id alone, and the response result IS the inner MCP result payload. Fix the detailed runtime sequence + the id-space note: mcp#7 lives only on the agent<->core HTTP hop; the core proxy maps its downstream mcp#7 <-> the upstream acp#55. (Was: "carried verbatim agent<->core<->extension".) Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 9e4698749..c1d8cc08c 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -241,11 +241,11 @@ Precondition: session open, extension WS attached, tools/list already discovered .............................................................................. 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 - payload=[ mcp#7 tools/call ] id=acp#55 + params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 5 G ==WS===> E server->client request (T1) = MCP-over-ACP outer id=acp#55 <-off-pod 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks - 7 G <==WS== E response payload=[ mcp#7 result:{ok,url:"/thanks"} ] outer id=acp#55 <-on-pod - 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> extracts inner mcp#7 + 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 .............................................................................. 10 A LLM consumes the tool result, keeps reasoning @@ -253,9 +253,12 @@ Precondition: session open, extension WS attached, tools/list already discovered 12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod -------------------------------------------------------------------------------- Two id spaces (never mixed) - - mcp#7 = MCP-layer id, carried verbatim agent<->core<->extension (steps 3->5->7->9) - - acp#55 = outer ACP-envelope id on the upstream tunnel; only the gateway pending-map - tracks it (steps 4<->8) + - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the + MCP-over-ACP RFD, mcp/message FLATTENS the inner method/params and does NOT + carry an inner MCP id, so mcp#7 never travels on the tunnel. + - acp#55 = outer ACP-envelope id that correlates the whole upstream tunnel round-trip + (steps 4<->8); the response result IS the inner MCP result payload. The core + proxy maps its downstream mcp#7 <-> the upstream acp#55. - acp#1 = downstream ACP permission id; unrelated to the two above Only steps 5/7/12 leave the pod (all on the /acp WS). Permission (1-2) and tool transport From d91814e5737a485f21e6eafbb1badb4089f71f1a Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 01:41:14 +0800 Subject: [PATCH 10/46] feat(gateway/acp): parse + record client-declared type:acp mcpServers (T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser extension declares its MCP-over-ACP server in session/new via the RFD's mcpServers entry {"type":"acp","id":...,"name":...}. Parse those (raw, since the "acp" transport is an RFD proposal not in the generated schema) and record them per session (AcpSession.acp_mcp_servers), so the gateway can later mcp/connect to them (T5.3). session/resume re-records them since the client re-presents mcpServers. http/sse/stdio servers are ignored (the agent connects to those itself). D5-agnostic: needed regardless of the core MCP server topology. Field is #[allow(dead_code)] until the mcp/connect wiring consumes it. Tests: parse keeps only acp entries (+ empty cases); session/new records them. Not done here: advertising mcpCapabilities.acp in initialize — the generated McpCapabilities has only http/sse (the RFD acp flag isn't in stable v1), and since we own both ends the extension can declare type:acp unconditionally; left as a follow-up. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 3cf923fc4..3baff8fd2 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -259,6 +259,41 @@ struct AcpSession { /// "cancelled"` to the prompt's own request id (rather than hard-aborting /// the task and orphaning that id). cancel: Option>, + /// Client-declared MCP-over-ACP servers (the RFD `{"type":"acp"}` mcpServers entries): + /// the browser extension serves its MCP tools over this same /acp WS. Recorded so the + /// gateway can later `mcp/connect` to them (T5.3). Unused until that wiring lands. + #[allow(dead_code)] + acp_mcp_servers: Vec, +} + +/// A client-declared MCP-over-ACP server (the RFD `"type":"acp"` `mcpServers` entry). Not in +/// the generated schema (the RFD is a proposal), so parsed from raw params. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +struct AcpMcpServer { + id: String, + name: String, +} + +/// Extract the `"type":"acp"` entries from a `session/new` / `session/resume` params +/// `mcpServers` array. http/sse/stdio servers are ignored here — the agent connects to those +/// itself; only the `acp`-transport ones are tunnelled over this WS. +fn parse_acp_mcp_servers(params: Option<&Value>) -> Vec { + params + .and_then(|p| p.get("mcpServers")) + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("acp")) + .filter_map(|e| { + Some(AcpMcpServer { + id: e.get("id").and_then(Value::as_str)?.to_string(), + name: e.get("name").and_then(Value::as_str)?.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() } pub enum ReplyChunk { @@ -774,7 +809,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } - let resp = handle_session_new(&sessions, id.clone()).await; + let acp_mcp_servers = parse_acp_mcp_servers(req.params.as_ref()); + let resp = handle_session_new(&sessions, id.clone(), acp_mcp_servers).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); } "session/resume" => { @@ -1000,6 +1036,7 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { async fn handle_session_new( sessions: &Arc>>, id: Value, + acp_mcp_servers: Vec, ) -> JsonRpcResponse { // sessionId and channel_id share one uuid so channel_id is always // re-derivable from a persisted sessionId (see session/resume). @@ -1013,6 +1050,7 @@ async fn handle_session_new( channel_id, busy: false, cancel: None, + acp_mcp_servers, }, ); @@ -1096,6 +1134,8 @@ async fn handle_session_resume( channel_id, busy: false, cancel: None, + // The client re-presents its mcpServers on resume; re-record the acp ones. + acp_mcp_servers: parse_acp_mcp_servers(params), }, ); drop(guard); @@ -1949,7 +1989,8 @@ mod acp_requests { #[cfg(test)] mod acp_handlers { use super::{ - handle_initialize, handle_session_new, handle_session_resume, AcpSession, JsonRpcRequest, + handle_initialize, handle_session_new, handle_session_resume, parse_acp_mcp_servers, + AcpMcpServer, AcpSession, JsonRpcRequest, }; use serde_json::json; use std::collections::HashMap; @@ -1999,12 +2040,47 @@ mod acp_handlers { #[tokio::test] async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); - let v = serde_json::to_value(handle_session_new(&sessions, json!(2)).await).unwrap(); + let v = serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); assert!(sessions.lock().await.contains_key(sid), "session must be stored"); } + #[test] + fn parse_acp_mcp_servers_keeps_only_acp_entries() { + let params = json!({ + "cwd": "/w", + "mcpServers": [ + {"type": "acp", "id": "srv-1", "name": "browser"}, + {"type": "http", "url": "http://x"}, + {"type": "acp", "id": "srv-2", "name": "other"} + ] + }); + assert_eq!( + parse_acp_mcp_servers(Some(¶ms)), + vec![ + AcpMcpServer { id: "srv-1".into(), name: "browser".into() }, + AcpMcpServer { id: "srv-2".into(), name: "other".into() }, + ] + ); + // no mcpServers -> empty + assert!(parse_acp_mcp_servers(Some(&json!({"cwd": "/w"}))).is_empty()); + assert!(parse_acp_mcp_servers(None).is_empty()); + } + + #[tokio::test] + async fn session_new_records_declared_acp_servers() { + let sessions = new_sessions(); + let servers = vec![AcpMcpServer { id: "srv-1".into(), name: "browser".into() }]; + let v = serde_json::to_value( + handle_session_new(&sessions, json!(2), servers.clone()).await, + ) + .unwrap(); + let sid = v["result"]["sessionId"].as_str().unwrap().to_string(); + let guard = sessions.lock().await; + assert_eq!(guard.get(&sid).unwrap().acp_mcp_servers, servers); + } + #[tokio::test] async fn session_resume_valid_stores_and_invalid_errors() { let sessions = new_sessions(); From 966119169fd6496fa12c64475378119b144c1d79 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 01:46:50 +0800 Subject: [PATCH 11/46] docs: MCP-over-ACP tunnel contract for the extension (T4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec the browser extension (katashiro, T6) implements: the gateway<->extension hop of MCP-over-ACP. Covers the type:acp session/new declaration, mcp/connect -> connectionId, mcp/message (inner method/params flattened, correlate by outer ACP id, result = inner MCP result), mcp/disconnect, the baseline browser tool set (click/read_dom/navigate/type/screenshot), and that permissions are auto-approved (D1) + the WS may attach after session start (D4). D5-agnostic (only the external hop; OpenAB-internal proxy/topology is out of scope), so it lets the extension side proceed in parallel. Linked from ADR §7 T4. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 5 +- docs/mcp-over-acp-tunnel-contract.md | 121 +++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 docs/mcp-over-acp-tunnel-contract.md diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index c1d8cc08c..1c214e1ba 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -325,8 +325,9 @@ auto-replying). Fine-grained permission control is a later, separate effort. hand-rolled envelope (D3). - 4.1 `mcp/connect` (→ `connectionId`) + `mcp/message` (carries the inner MCP JSON-RPC; outer ACP id ↔ pending-map, inner MCP id opaque) + `mcp/disconnect`. 4.2 Gateway routes - these between the `/acp` client (extension) and core. 4.3 Contract doc (the spec the - extension implements). 4.4 Mock-MCP-over-tunnel tests. + these between the `/acp` client (extension) and core. 4.3 Contract doc — done: + [MCP-over-ACP tunnel — extension implementation contract](../mcp-over-acp-tunnel-contract.md). + 4.4 Mock-MCP-over-tunnel tests. **T5 — OpenAB core = MCP proxy/aggregator.** - 5.1 A core-side **Streamable-HTTP MCP server hosted in-process** on `127.0.0.1:` diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md new file mode 100644 index 000000000..157bff0f1 --- /dev/null +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -0,0 +1,121 @@ +# MCP-over-ACP tunnel — extension implementation contract + +This is the wire contract the **browser extension** (the ACP client / MCP server end) +implements so the OpenAB gateway can tunnel MCP to it over the existing `/acp` WebSocket, per +[ADR: Browser control via MCP-over-ACP](./adr/acp-server-websocket-mcp-browser.md). It adopts +the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp). + +Scope: only the **gateway ↔ extension** hop (the sole hop that leaves the pod). How OpenAB +routes tool calls internally (core-hosted MCP proxy, agent subprocess) is out of scope for +the extension and may change without affecting this contract. + +## Roles + +- **Extension = ACP client + MCP server.** It opens the `/acp` WS, drives the chat session, + and *serves* the browser MCP tools over that same socket. +- **Gateway = ACP server + MCP client (connector).** It initiates `mcp/connect` / + `mcp/message` / `mcp/disconnect` toward the extension. + +An MV3 extension cannot open a listening socket, so MCP is tunnelled over the outbound WS the +extension already holds — that is the whole point of this contract. + +## 1. Transport + auth (unchanged from the base) + +`GET /acp` WebSocket. Bearer auth via the `Sec-WebSocket-Protocol` offer +`openab.bearer., acp.v1`; the server echoes `acp.v1`. All frames are JSON-RPC 2.0. + +## 2. Declaring the MCP server (in `session/new`) + +When the extension creates a session it declares its browser MCP server in the `mcpServers` +array with the `acp` transport type: + +```json +{ "method": "session/new", + "params": { + "cwd": "...", + "mcpServers": [ + { "type": "acp", "id": "", "name": "browser" } + ] } } +``` + +- `id` is extension-generated and stable for the session; the gateway uses it as the `acpId` + in `mcp/connect`. +- The gateway records this declaration per session (it does not yet act on it until it + connects — see §3). + +## 3. Opening the tunnel — `mcp/connect` (gateway → extension, request) + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/connect", "params": { "acpId":"" } } +``` + +Extension replies with a fresh, extension-assigned connection handle: + +```json +{ "jsonrpc":"2.0", "id":, "result": { "connectionId":"" } } +``` + +`connectionId` scopes all subsequent `mcp/message` traffic for this MCP connection. + +## 4. Carrying MCP — `mcp/message` (bidirectional) + +The inner MCP method + params are **flattened** into the `mcp/message` params (there is **no** +inner MCP `id`; correlation is by the outer ACP JSON-RPC id): + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/message", + "params": { "connectionId":"", "method":"", "params": { ... } } } +``` + +- **Request** (outer frame has `id`): the extension executes the inner MCP method and replies + with the **inner MCP result as the ACP response `result`**: + ```json + { "jsonrpc":"2.0", "id":, "result": { ...inner MCP result... } } + ``` + An inner MCP-level error is returned as the outer JSON-RPC `error`. +- **Notification** (outer frame has no `id`): fire-and-forget inner MCP notification; no + reply. The **extension** sends these upward for server-originated MCP notifications (e.g. + `notifications/tools/list_changed` when its tool set changes). + +Inner MCP methods the extension must handle as a server: +- `initialize` → advertise `capabilities.tools`. +- `tools/list` → return the browser tools (§6). +- `tools/call` → execute the named tool in the active tab; return an MCP `CallToolResult`. + +## 5. Closing — `mcp/disconnect` (gateway → extension, request) + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/disconnect", "params": { "connectionId":"" } } +``` +Extension releases the connection and replies `{ "result": {} }`. + +## 6. Browser tools (the MCP tools the extension serves) + +Baseline DOM-semantic set (model-agnostic; OpenAB also static-advertises these so they appear +even before the extension attaches). `tools/call` executes in the **active tab**. + +| name | arguments | behaviour | +|---|---|---| +| `browser.click` | `{ "selector": string }` | click the element matching the CSS selector | +| `browser.read_dom` | `{ "selector"?: string }` | return a DOM snapshot (optionally scoped) | +| `browser.navigate` | `{ "url": string }` | navigate the active tab to the URL | +| `browser.type` | `{ "selector": string, "text": string }` | type text into the matched element | +| `browser.screenshot` | `{}` | capture a screenshot of the active tab | + +`tools/call` returns an MCP `CallToolResult` (`{ "content": [ { "type":"text", "text":... } ] }`, +or an image content block for `screenshot`). On failure return an MCP tool error result. The +extension MAY expose additional tools beyond this baseline; they surface to the agent via +`tools/list` + a `tools/list_changed` notification. + +## 7. Permissions + +OpenAB core auto-approves tool permissions today (ADR D1); the extension does **not** need a +per-call consent UX yet. Fine-grained consent is a later addition to this contract. + +## Notes for implementers + +- One `connectionId` per `mcp/connect`; the gateway may reconnect (the MCP server / HTTP + proxy inside OpenAB is decoupled from the WS lifecycle, so the extension may attach after a + session has already started — ADR D4). +- Never assume an inner MCP `id`; always correlate by the outer ACP frame `id`. +- Keep tool execution idempotent where possible; the agent may retry. From 767c9ee00bbd7db8dc10762c3fc978f772e9abdd Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:05:48 +0800 Subject: [PATCH 12/46] =?UTF-8?q?feat(gateway/acp):=20TunnelHandle=20?= =?UTF-8?q?=E2=80=94=20per-connection=20MCP=20tunnel=20handle=20(T5.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable abstraction the core MCP proxy needs to reach a specific browser: TunnelHandle bundles one /acp connection's outbound channel + pending-request map + id counter + the mcp/connect connectionId, and exposes async mcp_message() / disconnect() that tunnel an inner MCP request to that extension and await the result. Built on the T1/T4.1 send_request + mcp_message_request helpers. D5-agnostic: both the per-session and shared core-server designs route through this same handle. Round-trip test via a mock extension driver. Next: register a TunnelHandle per session's channel_id (after mcp/connect) in a shared registry (AppState), and consume it from the core ProxyHandler. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 3baff8fd2..098ba1ed1 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -633,6 +633,55 @@ async fn mcp_disconnect( .map(|_| ()) } +/// A cloneable handle to one `/acp` connection's MCP-over-ACP tunnel (T5.3). Bundles the +/// per-connection outbound channel + pending-request map + id counter + the `connectionId` +/// from `mcp/connect`, so a holder can issue `mcp/message` requests to that browser and await +/// the result. Built by the gateway once the tunnel is open and (next) registered under the +/// session's `channel_id` in a shared registry, so the core MCP proxy can route a tool call +/// to the right browser. D5-agnostic: both the per-session and shared core-server designs use +/// this same handle. +#[derive(Clone)] +pub struct TunnelHandle { + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + connection_id: String, +} + +impl TunnelHandle { + /// Tunnel an inner MCP request (`tools/list`, `tools/call`, …) to the extension over this + /// connection and return the inner MCP result payload. + pub async fn mcp_message( + &self, + method: &str, + params: Option, + timeout_secs: u64, + ) -> Result { + mcp_message_request( + &self.out_tx, + &self.pending, + &self.next_id, + &self.connection_id, + method, + params, + timeout_secs, + ) + .await + } + + /// Close this tunnel (`mcp/disconnect`). + pub async fn disconnect(&self, timeout_secs: u64) -> Result<(), String> { + mcp_disconnect( + &self.out_tx, + &self.pending, + &self.next_id, + &self.connection_id, + timeout_secs, + ) + .await + } +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -1984,6 +2033,36 @@ mod acp_requests { assert_eq!(result["tools"][0]["name"], json!("browser.click")); ext.await.unwrap(); } + + #[tokio::test] + async fn tunnel_handle_mcp_message_roundtrips() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let handle = super::TunnelHandle { + out_tx, + pending: pending.clone(), + next_id, + connection_id: "conn-9".into(), + }; + + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/message")); + assert_eq!(f["params"]["connectionId"], json!("conn-9")); + assert_eq!(f["params"]["method"], json!("tools/call")); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"ok":true}})) + .await; + }); + + let result = handle + .mcp_message("tools/call", Some(json!({"name": "browser.click"})), 5) + .await + .unwrap(); + assert_eq!(result["ok"], json!(true)); + ext.await.unwrap(); + } } #[cfg(test)] From daa23dabb2df7978c031cd55fcfbc8bd9f052de2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:19:42 +0800 Subject: [PATCH 13/46] feat(gateway/acp): tunnel registry + establish_and_register_tunnel (T5.3) - AcpTunnelRegistry (channel_id -> TunnelHandle), mirroring AcpReplyRegistry, so the core MCP proxy can look up the tunnel for a given browser session. - establish_and_register_tunnel: mcp/connect to a session's declared "type":"acp" server, build a TunnelHandle from the returned connectionId, and register it under the session's channel_id. First real caller of mcp_connect/send_request. Documented as spawn-only (awaiting mcp_connect inline in the read loop would deadlock, since only that loop delivers the response). Test: a mock extension answers mcp/connect; the handle lands in the registry keyed by channel_id. #[allow(dead_code)] until the read loop spawns it and it's threaded through AppState (next). Then the core ProxyHandler consumes the registry to forward tools/list + tools/call. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 098ba1ed1..a7c3b14e8 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -323,6 +323,16 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } +/// Registry of open MCP-over-ACP tunnels: channel_id → `TunnelHandle`. The gateway inserts a +/// handle once it has `mcp/connect`ed to a session's browser extension server; the core MCP +/// proxy looks one up to route a tool call to the right browser (T5.3). Same std::sync::Mutex +/// rationale as `AcpReplyRegistry`. +pub type AcpTunnelRegistry = Arc>>; + +pub fn new_tunnel_registry() -> AcpTunnelRegistry { + Arc::new(std::sync::Mutex::new(HashMap::new())) +} + // --------------------------------------------------------------------------- // JSON-RPC types (minimal subset for ACP) // --------------------------------------------------------------------------- @@ -682,6 +692,35 @@ impl TunnelHandle { } } +/// Open the MCP-over-ACP tunnel to a session's declared `"type":"acp"` server and register a +/// `TunnelHandle` under the session's `channel_id` so the core MCP proxy can reach it (T5.3). +/// MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits +/// the client's response, which only that same read loop can deliver — awaiting it inline +/// would deadlock. +#[allow(dead_code)] +async fn establish_and_register_tunnel( + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + acp_id: String, + channel_id: String, + registry: AcpTunnelRegistry, + timeout_secs: u64, +) -> Result<(), String> { + let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; + let handle = TunnelHandle { + out_tx, + pending, + next_id, + connection_id, + }; + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(channel_id, handle); + Ok(()) +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -2063,6 +2102,42 @@ mod acp_requests { assert_eq!(result["ok"], json!(true)); ext.await.unwrap(); } + + #[tokio::test] + async fn establish_tunnel_registers_handle_under_channel_id() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let registry = super::new_tunnel_registry(); + + // mock extension: answer mcp/connect with a connectionId + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/connect")); + assert_eq!(f["params"]["acpId"], json!("srv-1")); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}})) + .await; + }); + + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "srv-1".into(), + "acp_abc".into(), + registry.clone(), + 5, + ) + .await + .unwrap(); + ext.await.unwrap(); + + assert!( + registry.lock().unwrap().contains_key("acp_abc"), + "a TunnelHandle must be registered under the session channel_id" + ); + } } #[cfg(test)] From eedf6ce81e4d475f98f511014219aa80ae50d7df Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:36:44 +0800 Subject: [PATCH 14/46] feat(gateway): thread AcpTunnelRegistry through AppState (T5.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add acp_tunnel_registry: Option to AppState alongside acp_reply_registry (same #[cfg(feature="acp")] gate), initialized wherever the reply registry is. This is the shared handle the connection read loop will populate (spawning establish_and_register_tunnel per declared type:acp server) and the core MCP proxy will consume to route a tool call to the right browser. No behaviour change yet — the field is constructed but not read until the read-loop spawn wiring lands next. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-gateway/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index b64a4e5e6..b730c90f0 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -62,6 +62,8 @@ pub struct AppState { pub acp: Option, #[cfg(feature = "acp")] pub acp_reply_registry: Option, + #[cfg(feature = "acp")] + pub acp_tunnel_registry: Option, pub ws_token: Option, pub event_tx: broadcast::Sender, pub reply_token_cache: ReplyTokenCache, @@ -105,6 +107,8 @@ impl AppState { acp: None, #[cfg(feature = "acp")] acp_reply_registry: None, + #[cfg(feature = "acp")] + acp_tunnel_registry: None, ws_token: None, event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -182,6 +186,8 @@ impl AppState { let acp = adapters::acp_server::AcpConfig::from_env(); #[cfg(feature = "acp")] let acp_reply_registry = acp.as_ref().map(|_| adapters::acp_server::new_reply_registry()); + #[cfg(feature = "acp")] + let acp_tunnel_registry = acp.as_ref().map(|_| adapters::acp_server::new_tunnel_registry()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -212,6 +218,8 @@ impl AppState { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, ws_token, event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -695,6 +703,10 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { let acp_reply_registry = acp .as_ref() .map(|_| adapters::acp_server::new_reply_registry()); + #[cfg(feature = "acp")] + let acp_tunnel_registry = acp + .as_ref() + .map(|_| adapters::acp_server::new_tunnel_registry()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -729,6 +741,8 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, ws_token, event_tx, reply_token_cache, From b88a176c7e538be80dda7689a0e1f9e49f64d43b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:50:02 +0800 Subject: [PATCH 15/46] feat(gateway/acp): open MCP tunnels on session/new + cleanup (T5.3) Wire the tunnel producer into the connection read loop: - handle_acp_connection mints a per-connection next_req_id (Arc) for server-initiated requests. - handle_session_new returns the minted channel_id alongside the response. - On session/new, for each declared "type":"acp" server, tokio::spawn establish_and_register_tunnel (mcp/connect -> register a TunnelHandle under the channel_id). Spawned, never awaited inline: it awaits mcp/connect whose response only this same read loop delivers, so awaiting inline would deadlock. The task is tracked in prompt_tasks (aborted on disconnect). - Disconnect cleanup now removes the connection's channel_ids from BOTH the reply and tunnel registries (gathered once). Live behaviour needs a real extension (T7); this lands the plumbing + keeps the unit tests green. Next: the core ProxyHandler consumes acp_tunnel_registry to forward tools/list + tools/call to the right browser. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 72 ++++++++++++++----- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index a7c3b14e8..53320312a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -739,6 +739,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // `route_client_response` (inbound) and `send_request` (outbound, wired in T1.4). let pending_requests: Arc>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + // Monotonic id source for server-initiated requests (mcp/connect, mcp/message). + let next_req_id = Arc::new(AtomicU64::new(1)); let mut initialized = false; // Track spawned prompt tasks so we can abort on disconnect @@ -898,8 +900,32 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { continue; } let acp_mcp_servers = parse_acp_mcp_servers(req.params.as_ref()); - let resp = handle_session_new(&sessions, id.clone(), acp_mcp_servers).await; + let (resp, channel_id) = + handle_session_new(&sessions, id.clone(), acp_mcp_servers.clone()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // If the client declared "type":"acp" MCP servers, open + register a tunnel to + // each so the core MCP proxy can reach this browser. SPAWNED, not awaited + // inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response + // only THIS read loop delivers — awaiting inline would deadlock. + if let Some(registry) = state.acp_tunnel_registry.clone() { + for srv in acp_mcp_servers { + let out_tx2 = out_tx.clone(); + let pending2 = pending_requests.clone(); + let next_id2 = next_req_id.clone(); + let channel = channel_id.clone(); + let reg = registry.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = establish_and_register_tunnel( + out_tx2, pending2, next_id2, srv.id, channel, reg, 30, + ) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); + } + })); + } + } } "session/resume" => { if !initialized { @@ -1043,25 +1069,31 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { handle.abort(); } - // Remove all sessions for this connection from the reply registry - if let Some(ref registry) = state.acp_reply_registry { + // Remove all of this connection's sessions from the reply + tunnel registries. + let channel_ids: Vec = { let sessions_guard = sessions.lock().await; - let channel_ids: Vec = sessions_guard + sessions_guard .values() .map(|s| s.channel_id.clone()) - .collect(); - drop(sessions_guard); - + .collect() + }; + if let Some(ref registry) = state.acp_reply_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); for cid in &channel_ids { reg.remove(cid); } - debug!( - connection = %connection_id, - sessions_cleaned = channel_ids.len(), - "ACP connection cleanup complete" - ); } + if let Some(ref registry) = state.acp_tunnel_registry { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + for cid in &channel_ids { + reg.remove(cid); + } + } + debug!( + connection = %connection_id, + sessions_cleaned = channel_ids.len(), + "ACP connection cleanup complete" + ); send_task.abort(); info!(connection = %connection_id, "ACP client disconnected"); @@ -1121,11 +1153,13 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { ) } +/// Returns the response plus the minted `channel_id`, so the caller can open the +/// MCP-over-ACP tunnel(s) for any declared `"type":"acp"` servers under that key. async fn handle_session_new( sessions: &Arc>>, id: Value, acp_mcp_servers: Vec, -) -> JsonRpcResponse { +) -> (JsonRpcResponse, String) { // sessionId and channel_id share one uuid so channel_id is always // re-derivable from a persisted sessionId (see session/resume). let uuid = Uuid::new_v4(); @@ -1135,7 +1169,7 @@ async fn handle_session_new( sessions.lock().await.insert( session_id.clone(), AcpSession { - channel_id, + channel_id: channel_id.clone(), busy: false, cancel: None, acp_mcp_servers, @@ -1154,7 +1188,10 @@ async fn handle_session_new( meta: None, modes: None, }; - JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) + ( + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()), + channel_id, + ) } /// `session/resume` — re-attach to a session the client persisted, WITHOUT @@ -2194,7 +2231,8 @@ mod acp_handlers { #[tokio::test] async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); - let v = serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await).unwrap(); + let v = + serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await.0).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); assert!(sessions.lock().await.contains_key(sid), "session must be stored"); @@ -2227,7 +2265,7 @@ mod acp_handlers { let sessions = new_sessions(); let servers = vec![AcpMcpServer { id: "srv-1".into(), name: "browser".into() }]; let v = serde_json::to_value( - handle_session_new(&sessions, json!(2), servers.clone()).await, + handle_session_new(&sessions, json!(2), servers.clone()).await.0, ) .unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap().to_string(); From 4af5ad9f0962546bda8d84d2065360b65a3e22ff Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:28:37 +0800 Subject: [PATCH 16/46] feat(core): BrowserTunnel trait + ProxyHandler forwards tool calls (T5.3, D6-a') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the core-side tunnel interface (D6-a'): `trait BrowserTunnel { async fn call(channel_id, method, params) }`, implemented by the root (bridging to the gateway registry) so no core<->gateway crate dependency is introduced — matching the existing ChatAdapter pattern. - ProxyHandler now carries its session channel_id + an Option> (D5-a: one server per session). call_tool forwards the tool as an MCP tools/call over the tunnel; list_tools stays static-advertised (D4); no tunnel / no browser attached -> "browser not connected" (D4). - spawn_mcp_server takes (channel_id, tunnel) and builds a per-session ProxyHandler in the service factory. Tests: forward via a mock BrowserTunnel; not-connected without one. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 112 ++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index ec537eedc..c6ed459a3 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -11,8 +11,8 @@ //! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. use rmcp::model::{ - object, CallToolRequestParams, CallToolResult, ErrorData as McpError, ListToolsResult, - PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + object, CallToolRequestParams, CallToolResult, ErrorData as McpError, JsonObject, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }; use rmcp::service::{RequestContext, RoleServer}; use rmcp::transport::streamable_http_server::{ @@ -20,9 +20,26 @@ use rmcp::transport::streamable_http_server::{ }; use rmcp::ServerHandler; use axum::response::IntoResponse; -use serde_json::json; +use serde_json::{json, Value}; use std::sync::Arc; +/// Core-side interface to the browser MCP-over-ACP tunnel (D6-a'). Implemented by the ROOT +/// (which bridges to the gateway's per-connection tunnel registry) and consumed by the MCP +/// proxy here. Keeping the trait in core with the impl in root preserves the core/gateway +/// sibling independence, matching the existing `ChatAdapter` pattern. +#[async_trait::async_trait] +pub trait BrowserTunnel: Send + Sync { + /// Forward an inner MCP request (e.g. `tools/call`) to the browser session identified by + /// `channel_id` and return the inner MCP result payload. Err if no browser is currently + /// attached to that session. + async fn call( + &self, + channel_id: &str, + method: &str, + params: Option, + ) -> Result; +} + /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. pub(crate) fn browser_tools() -> Vec { @@ -77,8 +94,42 @@ pub(crate) fn browser_tools() -> Vec { /// the browser tools and (once T5.3 wires the tunnel) forwards `tools/call` to the extension /// over MCP-over-ACP. Until then it static-advertises (D4) and returns "browser not /// connected" on call. -#[derive(Clone, Default)] -pub struct ProxyHandler {} +#[derive(Clone)] +pub struct ProxyHandler { + /// The browser session this server instance serves (D5-a: one MCP server per session). + channel_id: String, + /// Bridge to that session's browser tunnel; `None` when no browser is attached (or the + /// process has no tunnel wiring). A call while `None` reports "browser not connected" (D4). + tunnel: Option>, +} + +impl ProxyHandler { + pub fn new(channel_id: String, tunnel: Option>) -> Self { + Self { channel_id, tunnel } + } + + /// Forward a tool call to the browser over the tunnel (as an MCP `tools/call`), or report + /// not-connected (D4) when no browser is attached. + async fn forward_tool_call( + &self, + name: &str, + arguments: Option, + ) -> Result { + let Some(tunnel) = &self.tunnel else { + return Err(McpError::internal_error( + "browser not connected: open the OpenAB side panel in your browser", + None, + )); + }; + let params = json!({ "name": name, "arguments": arguments }); + let result = tunnel + .call(&self.channel_id, "tools/call", Some(params)) + .await + .map_err(|e| McpError::internal_error(e, None))?; + serde_json::from_value(result) + .map_err(|e| McpError::internal_error(format!("malformed tool result: {e}"), None)) + } +} impl ServerHandler for ProxyHandler { fn get_info(&self) -> ServerInfo { @@ -102,14 +153,11 @@ impl ServerHandler for ProxyHandler { async fn call_tool( &self, - _request: CallToolRequestParams, + request: CallToolRequestParams, _context: RequestContext, ) -> Result { - // No extension wired yet (T5.3). Per D4, fail gracefully rather than hide the tool. - Err(McpError::internal_error( - "browser not connected: open the OpenAB side panel in your browser", - None, - )) + self.forward_tool_call(request.name.as_ref(), request.arguments) + .await } } @@ -140,6 +188,8 @@ async fn require_bearer( /// same `bearer` to the colocated agent's native MCP config (T5.2). Shuts down when `ct` is /// cancelled. pub async fn spawn_mcp_server( + channel_id: String, + tunnel: Option>, bearer: String, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result { @@ -150,7 +200,7 @@ pub async fn spawn_mcp_server( .with_cancellation_token(ct.child_token()); let service: StreamableHttpService = StreamableHttpService::new( - || Ok(ProxyHandler::default()), + move || Ok(ProxyHandler::new(channel_id.clone(), tunnel.clone())), Default::default(), config, ); @@ -172,7 +222,39 @@ pub async fn spawn_mcp_server( #[cfg(test)] mod tests { - use super::{browser_tools, spawn_mcp_server}; + use super::{browser_tools, spawn_mcp_server, BrowserTunnel, ProxyHandler}; + + struct MockTunnel; + #[async_trait::async_trait] + impl BrowserTunnel for MockTunnel { + async fn call( + &self, + channel_id: &str, + method: &str, + _params: Option, + ) -> Result { + assert_eq!(channel_id, "acp_x"); + assert_eq!(method, "tools/call"); + Ok(serde_json::json!({"content": [{"type": "text", "text": "clicked"}]})) + } + } + + #[tokio::test] + async fn call_tool_forwards_to_the_tunnel() { + let h = ProxyHandler::new("acp_x".into(), Some(std::sync::Arc::new(MockTunnel))); + let result = h.forward_tool_call("browser.click", None).await.unwrap(); + let v = serde_json::to_value(&result).unwrap(); + assert_eq!(v["content"][0]["text"], serde_json::json!("clicked")); + } + + #[tokio::test] + async fn call_tool_reports_not_connected_without_a_tunnel() { + let h = ProxyHandler::new("acp_x".into(), None); + assert!( + h.forward_tool_call("browser.click", None).await.is_err(), + "a call with no attached browser must error (D4)" + ); + } #[test] fn browser_tools_advertises_the_fixed_set() { @@ -208,7 +290,7 @@ mod tests { #[tokio::test] async fn mcp_server_binds_loopback_and_initializes_with_bearer() { let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) .await .unwrap(); assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); @@ -237,7 +319,7 @@ mod tests { #[tokio::test] async fn mcp_server_rejects_missing_or_wrong_bearer() { let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) .await .unwrap(); let url = format!("http://{addr}/mcp"); From c9084964e488129509d5606202dac77362842285 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:33:11 +0800 Subject: [PATCH 17/46] =?UTF-8?q?feat(core):=20start=5Fsession=5Fserver=20?= =?UTF-8?q?=E2=80=94=20per-session=20server=20+=20.cursor/mcp.json=20(T5.2?= =?UTF-8?q?/D5-a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_session_server(channel_id, workdir, tunnel): mint a fresh bearer, start the loopback MCP proxy for that session, and MERGE an `openab-browser` HTTP entry (url + Authorization: Bearer) into /.cursor/mcp.json without clobbering any servers already there (Cursor's native config; D2). Returns the bound addr + a CancellationToken the pool cancels to stop the server on session evict. Tests: writes the cursor config (url+bearer); merges into an existing mcp.json. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 83 ++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c6ed459a3..6ef0181af 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -220,9 +220,44 @@ pub async fn spawn_mcp_server( Ok(addr) } +/// Start a per-session MCP proxy server (D5-a) and register it in the agent's native MCP +/// config so the colocated agent connects to it (D2). Mints a fresh bearer, starts the +/// loopback server, and writes/merges `/.cursor/mcp.json` with the `openab-browser` +/// HTTP entry (Cursor's config; other agents get their own writer later). Returns the bound +/// address + the `CancellationToken` the caller cancels to stop the server on session evict. +pub async fn start_session_server( + channel_id: &str, + workdir: &str, + tunnel: Option>, +) -> std::io::Result<(std::net::SocketAddr, tokio_util::sync::CancellationToken)> { + let bearer = uuid::Uuid::new_v4().to_string(); + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server(channel_id.to_string(), tunnel, bearer.clone(), ct.clone()).await?; + + // Merge the openab-browser entry into /.cursor/mcp.json (don't clobber any + // servers the user/agent already configured). + let cursor_dir = std::path::Path::new(workdir).join(".cursor"); + tokio::fs::create_dir_all(&cursor_dir).await?; + let cfg_path = cursor_dir.join("mcp.json"); + let mut cfg: Value = match tokio::fs::read(&cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + cfg["mcpServers"]["openab-browser"] = json!({ + "url": format!("http://{addr}/mcp"), + "headers": { "Authorization": format!("Bearer {bearer}") } + }); + tokio::fs::write(&cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + + Ok((addr, ct)) +} + #[cfg(test)] mod tests { - use super::{browser_tools, spawn_mcp_server, BrowserTunnel, ProxyHandler}; + use super::{browser_tools, spawn_mcp_server, start_session_server, BrowserTunnel, ProxyHandler}; struct MockTunnel; #[async_trait::async_trait] @@ -256,6 +291,52 @@ mod tests { ); } + #[tokio::test] + async fn start_session_server_writes_cursor_config() { + let dir = tempfile::tempdir().unwrap(); + let (addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) + .await + .unwrap(); + assert!(addr.ip().is_loopback()); + + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join(".cursor/mcp.json")).unwrap()) + .unwrap(); + let entry = &cfg["mcpServers"]["openab-browser"]; + assert_eq!(entry["url"], serde_json::json!(format!("http://{addr}/mcp"))); + assert!(entry["headers"]["Authorization"] + .as_str() + .unwrap() + .starts_with("Bearer ")); + ct.cancel(); + } + + #[tokio::test] + async fn start_session_server_merges_existing_config() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, + ) + .unwrap(); + let (_addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) + .await + .unwrap(); + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert!( + cfg["mcpServers"]["other"].is_object(), + "existing server must be preserved" + ); + assert!( + cfg["mcpServers"]["openab-browser"].is_object(), + "openab-browser must be added" + ); + ct.cancel(); + } + #[test] fn browser_tools_advertises_the_fixed_set() { let tools = browser_tools(); From 6cbd03f581eb70029e8fd122fdd6613c1e6e0ad3 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:41:28 +0800 Subject: [PATCH 18/46] feat(core): start per-session MCP proxy on agent spawn (T5.2/D5-a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the per-session MCP proxy into the pool's agent-launch path (feature acp-mcp): - For a browser (`acp:`) session, get_or_create starts a loopback MCP server + writes .cursor/mcp.json BEFORE spawning the agent, so the agent connects to it on boot. Non-acp sessions (Discord, etc.) are untouched. - Lifecycle: the server's CancellationToken drop_guard is stored INSIDE the AcpConnection (new mcp_server_guard field), so the server is cancelled whenever the connection is dropped — through any evict/suspend/hung-kill path — without touching each removal site. On a failed spawn/init the guard drops early and cancels too. - SessionPool gains a browser_tunnel field + with_browser_tunnel() builder (set by the root, D6-a'); passed into each per-session ProxyHandler. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/connection.rs | 13 +++++++ crates/openab-core/src/acp/pool.rs | 45 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index f40ceb486..cb9577ddd 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -189,6 +189,11 @@ pub struct AcpConnection { pub session_reset: bool, _reader_handle: JoinHandle<()>, _stderr_handle: Option>, + /// Cancels this session's per-session MCP proxy server (D5-a) when the connection is + /// dropped. Held only for its `Drop` side effect (never read). + #[cfg(feature = "acp-mcp")] + #[allow(dead_code)] + mcp_server_guard: Option, } /// Build the final set of env vars for the agent subprocess. @@ -485,9 +490,17 @@ impl AcpConnection { session_reset: false, _reader_handle: reader_handle, _stderr_handle: stderr_handle, + #[cfg(feature = "acp-mcp")] + mcp_server_guard: None, }) } + /// Attach the guard that stops this session's MCP proxy server when the connection drops. + #[cfg(feature = "acp-mcp")] + pub fn set_mcp_guard(&mut self, guard: Option) { + self.mcp_server_guard = guard; + } + fn next_id(&self) -> u64 { self.next_id.fetch_add(1, Ordering::Relaxed) } diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 394d9f260..e1e129116 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -53,6 +53,10 @@ pub struct SessionPool { mapping_path: PathBuf, meta_path: PathBuf, default_config_options: HashMap, + /// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the + /// root. `None` = no browser wiring (tool calls report not-connected). + #[cfg(feature = "acp-mcp")] + browser_tunnel: Option>, } type CancelHandle = (Arc>, String); @@ -197,9 +201,22 @@ impl SessionPool { mapping_path, meta_path, default_config_options, + #[cfg(feature = "acp-mcp")] + browser_tunnel: None, } } + /// Wire the browser tunnel bridge (D6-a', set by the root) so per-session MCP proxies can + /// reach the browser. Call before sharing the pool. + #[cfg(feature = "acp-mcp")] + pub fn with_browser_tunnel( + mut self, + tunnel: Option>, + ) -> Self { + self.browser_tunnel = tunnel; + self + } + fn load_mapping(path: &Path) -> HashMap { match std::fs::read_to_string(path) { Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { @@ -347,6 +364,32 @@ impl SessionPool { self.config.working_dir.clone() }; + // Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP + // server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The + // returned guard cancels that server when this connection is dropped (any evict path). + #[cfg(feature = "acp-mcp")] + let mcp_guard: Option = + if let Some(channel_id) = thread_id.strip_prefix("acp:") { + match crate::mcp_proxy::start_session_server( + channel_id, + &effective_workdir, + self.browser_tunnel.clone(), + ) + .await + { + Ok((addr, ct)) => { + info!(thread_id, %addr, "started per-session MCP proxy server"); + Some(ct.drop_guard()) + } + Err(e) => { + warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + None + } + } + } else { + None + }; + // Build the replacement connection outside the state lock so one stuck // initialization does not block all unrelated sessions. let mut new_conn = AcpConnection::spawn( @@ -423,6 +466,8 @@ impl SessionPool { let activity_handle = new_conn.activity_handle(); let child_pgid = new_conn.child_pgid(); let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default(); + #[cfg(feature = "acp-mcp")] + new_conn.set_mcp_guard(mcp_guard); let new_conn = Arc::new(Mutex::new(new_conn)); let mut state = self.state.write().await; From e582383221b8a70948d374e748694cfc1dd9d62c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:46:47 +0800 Subject: [PATCH 19/46] feat(root): wire the browser tunnel bridge end-to-end (T5.3, D6-a') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootBrowserTunnel (src/browser_tunnel.rs) implements openab-core's BrowserTunnel trait by looking up a channel_id in the gateway's AcpTunnelRegistry and calling TunnelHandle.mcp_message — the root glue that connects the two sibling crates without either depending on the other (mirrors the ChatAdapter pattern). Wiring in `openab run` (feature acp): create ONE shared acp_tunnel_registry before the pool; give the pool a RootBrowserTunnel over it (with_browser_tunnel), and inject the SAME registry into the gateway AppState so acp_server populates the exact map the bridge reads. This closes the loop: agent tools/call -> core per-session MCP proxy -> RootBrowserTunnel -> gateway TunnelHandle -> mcp/message -> extension. Live path still needs a real extension + deploy (T7); everything compiles + unit tests green. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- src/browser_tunnel.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 22 ++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/browser_tunnel.rs diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs new file mode 100644 index 000000000..e11ab5efa --- /dev/null +++ b/src/browser_tunnel.rs @@ -0,0 +1,39 @@ +//! Root-side bridge implementing the core `BrowserTunnel` trait (D6-a'). Reads the gateway's +//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the browser attached +//! to a given `channel_id`. This lives in the root binary — the only place that depends on +//! BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), preserving the two +//! crates' sibling independence (mirroring the existing `ChatAdapter` glue at the root). + +use openab_core::mcp_proxy::BrowserTunnel; +use openab_gateway::adapters::acp_server::AcpTunnelRegistry; +use serde_json::Value; + +pub struct RootBrowserTunnel { + registry: AcpTunnelRegistry, +} + +impl RootBrowserTunnel { + pub fn new(registry: AcpTunnelRegistry) -> Self { + Self { registry } + } +} + +#[async_trait::async_trait] +impl BrowserTunnel for RootBrowserTunnel { + async fn call( + &self, + channel_id: &str, + method: &str, + params: Option, + ) -> Result { + // Clone the handle out under the lock; never hold the std mutex across `.await`. + let handle = { + let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); + reg.get(channel_id).cloned() + }; + match handle { + Some(h) => h.mcp_message(method, params, 30).await, + None => Err(format!("no browser attached to session {channel_id}")), + } + } +} diff --git a/src/main.rs b/src/main.rs index 32b46853c..534a3e467 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod ctl; feature = "acp", ))] mod unified_adapter; +#[cfg(feature = "acp")] +mod browser_tunnel; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -377,14 +379,24 @@ async fn main() -> anyhow::Result<()> { let shutdown_hook = cfg.hooks.pre_shutdown.clone(); - let pool = Arc::new(acp::SessionPool::new( + // Shared MCP-over-ACP tunnel registry (D6-a'): the gateway populates it per browser + // session; the core MCP proxy reads it via the RootBrowserTunnel bridge below. + #[cfg(feature = "acp")] + let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); + + let pool_inner = acp::SessionPool::new( cfg.agent, cfg.pool.max_sessions, cfg.pool .prompt_hard_timeout_secs .saturating_add(cfg.pool.hung_grace_secs), cfg.pool.default_config_options, - )); + ); + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_browser_tunnel(Some(Arc::new( + browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), + ))); + let pool = Arc::new(pool_inner); let ttl_secs = cfg.pool.session_ttl_hours * 3600; // Resolve STT config (auto-detect GROQ_API_KEY from env) @@ -956,6 +968,12 @@ async fn main() -> anyhow::Result<()> { // Build gateway AppState from env vars (shared factory with standalone gateway) let mut gw_state_inner = openab_gateway::AppState::from_env(event_tx.clone(), None); + // Share the tunnel registry the core MCP proxy reads (D6-a'), so the gateway + // populates the same map the RootBrowserTunnel bridge looks up. + #[cfg(feature = "acp")] + { + gw_state_inner.acp_tunnel_registry = Some(acp_tunnel_registry.clone()); + } // First-class `[telegram]` config overrides env-derived values From c9896ce3d6d2030b36ec83c761a06bfa9649116b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:50:50 +0800 Subject: [PATCH 20/46] docs(adr): record the as-built OpenAB side (D5-a + D6-a', end-to-end) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a §7 "As-built" section documenting the two decisions settled during implementation and the realised call path: D5 = per-session MCP server bound to the existing channel_id map (lifetime tied to the AcpConnection via a CancellationToken DropGuard); D6 = BrowserTunnel trait in core + impl in the root (RootBrowserTunnel), keeping core/gateway sibling-independent like the existing ChatAdapter glue. Notes remaining T5.4 / T6 / T7. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 1c214e1ba..ea9f04694 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -356,3 +356,35 @@ T0 spike → T1 (server→client request direction) → T2 → **T4 (adopt the R → then T6 in parallel against the contract → T7. The heavy items are T1 (the direction), T4/T5 (tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is parallel and non-blocking. + +### As-built (2026-07-20) — OpenAB side wired end-to-end + +The OpenAB (server) side is implemented on `feat/acp-mcp-browser` (compiles + unit-tested; +live path pending the extension T6 + deploy T7). Two decisions beyond D1–D4 settled during +implementation: + +- **D5 = per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per + `acp:` session (in `openab-core/src/acp/pool.rs`, at agent spawn), constructing the + `ProxyHandler` with that session's `channel_id` so correlation is implicit — it binds to the + existing `session_key`/`channel_id` map, no in-band id. Server lifetime is tied to the + `AcpConnection` via a `CancellationToken` `DropGuard`, so it stops on any evict path. +- **D6 = tunnel trait in core, impl in root.** `openab-core` defines + `mcp_proxy::BrowserTunnel`; the **root** binary implements it (`src/browser_tunnel.rs`) + by looking up the gateway's `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. + This keeps `openab-core` and `openab-gateway` **sibling-independent** (no cross-crate dep), + mirroring the existing `ChatAdapter`/`GatewayResponse` root-glue pattern. + +Realised call path (all in one `openab run` process): + +``` +agent tools/call ─http▶ core per-session ProxyHandler (mcp_proxy.rs) + ─▶ BrowserTunnel (core trait) ─▶ RootBrowserTunnel (root, src/browser_tunnel.rs) + ─▶ gateway AcpTunnelRegistry[channel_id] ─▶ TunnelHandle::mcp_message + ═mcp/message═▶ extension (only this hop leaves the pod) +``` + +Config injection is per-agent (`.cursor/mcp.json` merged at the session workdir, loopback + +bearer). Static-advertise + not-connected fallback (D4) hold when no browser is attached. +**Remaining:** T5.4 `tools/list_changed` (enhancement; static-advertise already covers the +disconnected case), T6 extension (katashiro — see the +[tunnel contract](../mcp-over-acp-tunnel-contract.md)), and T7 live e2e + deploy. From 059da24914cd032f9556d1718417b6f5e203e18e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 04:00:07 +0800 Subject: [PATCH 21/46] test(e2e): MCP-over-ACP tunnel producer section in acp-ws-smoke (T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "MCP-over-ACP tunnel" section to the smoke suite: a mock extension declares a {type:acp} mcpServers entry in session/new, then asserts the gateway issues a server-initiated mcp/connect carrying the declared acpId, and answers it with a connectionId (registering the tunnel). This exercises the live read-loop spawn + server->client request path end-to-end — the concurrency unit tests can't reach. Runs against a live server (deploy T7). The tunnel path is inert for normal sessions (only triggers on a type:acp declaration), so it does not affect existing ACP traffic. Co-Authored-By: Claude Opus 4.8 --- scripts/acp-ws-smoke.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index cfc313edd..dc9de146d 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -314,6 +314,43 @@ async def section_lifecycle(): record("life", stop == "cancelled", "session/cancel → prompt ends stopReason:cancelled", f"got {stop!r}") +async def section_tunnel(): + """MCP-over-ACP tunnel producer (T5.3): declaring a `type:acp` MCP server in + session/new makes the gateway open a tunnel to us (a server-initiated mcp/connect + request); we answer and it registers the tunnel. This exercises the live read-loop + spawn path end-to-end (the concurrency that unit tests can't reach).""" + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + r = await c.call( + "session/new", + { + "cwd": "/home/agent", + "mcpServers": [{"type": "acp", "id": "srv-smoke", "name": "browser"}], + }, + ) + sid = r.get("result", {}).get("sessionId", "") + record("tunnel", sid.startswith("sess_"), "session/new with a type:acp mcpServers entry is accepted") + + # The gateway now issues a server-initiated mcp/connect. Wait for it. + connect = None + for _ in range(20): + try: + m = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + except asyncio.TimeoutError: + break + if m.get("method") == "mcp/connect": + connect = m + break + record("tunnel", connect is not None, "gateway sends a server-initiated mcp/connect after the type:acp declaration") + if connect: + params = connect.get("params", {}) + record("tunnel", params.get("acpId") == "srv-smoke", "mcp/connect carries the declared acpId", str(params)) + record("tunnel", connect.get("id") is not None, "mcp/connect is a request (has an id)") + await ws.send(json.dumps({"jsonrpc": "2.0", "id": connect["id"], "result": {"connectionId": "conn-smoke"}})) + record("tunnel", True, "answered mcp/connect with a connectionId (the tunnel registers)") + + async def main() -> int: if not TOKEN: print("ERROR: OPENAB_ACP_TOKEN is required (the /acp endpoint mandates a transport token off loopback).", file=sys.stderr) @@ -323,6 +360,7 @@ async def main() -> int: await section_compliance() await section_edges() await section_lifecycle() + await section_tunnel() total = len(results) passed = sum(1 for _, ok, _ in results if ok) @@ -333,7 +371,7 @@ async def main() -> int: if ok: s[0] += 1 print("\n" + "-" * 60, flush=True) - labels = {"auth": "Transport / Auth", "comp": "Protocol compliance", "edge": "Protocol edge cases", "life": "Lifecycle / transport"} + labels = {"auth": "Transport / Auth", "comp": "Protocol compliance", "edge": "Protocol edge cases", "life": "Lifecycle / transport", "tunnel": "MCP-over-ACP tunnel"} for sec, (p, t) in by_section.items(): print(f" {labels.get(sec, sec):22} {p}/{t}", flush=True) print(f"\nRESULT: {passed}/{total} checks passed", flush=True) From 6d5d1ce089643750a90e4849680da23da68b5d28 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 04:19:28 +0800 Subject: [PATCH 22/46] test(e2e): complete the MCP-over-ACP tunnel suite (fan-out + filtering) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the tunnel section from a single-server check to full producer coverage via a collect_mcp_connects() helper: single type:acp → exactly one mcp/connect; fan-out (two type:acp servers → one distinct mcp/connect each, distinct request ids); mixed acp+http mcpServers → only the acp entry is tunnelled. All deterministic. Validated live against Falcon: 34/34 (tunnel 7/7). The agent→tool→browser leg is out of the WS suite's reach (needs a real extension, T6). Run: OPENAB_ACP_TOKEN= uv run scripts/acp-ws-smoke.py ws:///acp Co-Authored-By: Claude Opus 4.8 --- scripts/acp-ws-smoke.py | 82 ++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index dc9de146d..d55f3cc01 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -314,41 +314,65 @@ async def section_lifecycle(): record("life", stop == "cancelled", "session/cancel → prompt ends stopReason:cancelled", f"got {stop!r}") +async def collect_mcp_connects(c: Conn, ws, mcp_servers, window=6.0): + """session/new with the given mcpServers, then collect the server-initiated mcp/connect + requests the gateway issues within `window` seconds, answering each with a connectionId. + Returns (sessionId, [mcp/connect frames]).""" + r = await c.call("session/new", {"cwd": "/home/agent", "mcpServers": mcp_servers}) + sid = r.get("result", {}).get("sessionId", "") + connects = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + window + while loop.time() < deadline: + try: + m = json.loads(await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - loop.time()))) + except asyncio.TimeoutError: + break + if m.get("method") == "mcp/connect": + connects.append(m) + await ws.send(json.dumps({"jsonrpc": "2.0", "id": m["id"], "result": {"connectionId": f"conn-{len(connects)}"}})) + return sid, connects + + async def section_tunnel(): - """MCP-over-ACP tunnel producer (T5.3): declaring a `type:acp` MCP server in - session/new makes the gateway open a tunnel to us (a server-initiated mcp/connect - request); we answer and it registers the tunnel. This exercises the live read-loop - spawn path end-to-end (the concurrency that unit tests can't reach).""" + """MCP-over-ACP tunnel producer (T5.3): a `type:acp` mcpServers entry makes the gateway + open a tunnel to us (a server-initiated mcp/connect). Covers the single case, fan-out over + multiple servers, and mixed-transport filtering. Exercises the live read-loop spawn path + the unit tests can't reach. (The agent→tool→browser leg needs a real extension — T6.)""" + # 1) single type:acp → exactly one mcp/connect carrying the declared id async with await try_connect(TOKEN) as ws: c = Conn(ws) await c.initialize() - r = await c.call( - "session/new", - { - "cwd": "/home/agent", - "mcpServers": [{"type": "acp", "id": "srv-smoke", "name": "browser"}], - }, - ) - sid = r.get("result", {}).get("sessionId", "") + sid, connects = await collect_mcp_connects(c, ws, [{"type": "acp", "id": "srv-solo", "name": "browser"}]) record("tunnel", sid.startswith("sess_"), "session/new with a type:acp mcpServers entry is accepted") + record("tunnel", len(connects) == 1, "single type:acp server → exactly one server-initiated mcp/connect", f"got {len(connects)}") + if connects: + p = connects[0].get("params", {}) + record("tunnel", p.get("acpId") == "srv-solo", "mcp/connect carries the declared acpId", str(p)) + record("tunnel", connects[0].get("id") is not None, "mcp/connect is a request (has an id)") - # The gateway now issues a server-initiated mcp/connect. Wait for it. - connect = None - for _ in range(20): - try: - m = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) - except asyncio.TimeoutError: - break - if m.get("method") == "mcp/connect": - connect = m - break - record("tunnel", connect is not None, "gateway sends a server-initiated mcp/connect after the type:acp declaration") - if connect: - params = connect.get("params", {}) - record("tunnel", params.get("acpId") == "srv-smoke", "mcp/connect carries the declared acpId", str(params)) - record("tunnel", connect.get("id") is not None, "mcp/connect is a request (has an id)") - await ws.send(json.dumps({"jsonrpc": "2.0", "id": connect["id"], "result": {"connectionId": "conn-smoke"}})) - record("tunnel", True, "answered mcp/connect with a connectionId (the tunnel registers)") + # 2) fan-out: two type:acp servers → one distinct mcp/connect each + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + _, connects = await collect_mcp_connects( + c, ws, [{"type": "acp", "id": "srv-a", "name": "a"}, {"type": "acp", "id": "srv-b", "name": "b"}] + ) + ids = sorted(x.get("params", {}).get("acpId") for x in connects) + record("tunnel", ids == ["srv-a", "srv-b"], "two type:acp servers → one mcp/connect each (fan-out)", str(ids)) + outer = [x.get("id") for x in connects] + record("tunnel", len(set(outer)) == len(outer) and all(i is not None for i in outer), + "each mcp/connect uses a distinct request id", str(outer)) + + # 3) mixed transports: only the acp server is tunnelled (http is the agent's own concern) + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + _, connects = await collect_mcp_connects( + c, ws, [{"type": "acp", "id": "srv-x", "name": "browser"}, {"type": "http", "url": "http://example/mcp"}] + ) + ids = [x.get("params", {}).get("acpId") for x in connects] + record("tunnel", ids == ["srv-x"], "mixed acp+http mcpServers → only the acp one gets mcp/connect", str(ids)) async def main() -> int: From ddc96203e78da3e1bf1ce356498a4423efdf7676 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 06:14:47 +0800 Subject: [PATCH 23/46] =?UTF-8?q?fix(acp-mcp):=20address=20Mira=20review?= =?UTF-8?q?=20nits=20=E2=80=94=20constant-time=20bearer=20+=20string-numbe?= =?UTF-8?q?r=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two non-blocking hardening nits from the openab-side review: - mcp_proxy require_bearer: compare the loopback MCP bearer in constant time (subtle::ConstantTimeEq, matching the gateway's feishu/wecom signature checks) so a wrong token can't be recovered byte-by-byte via response timing. Adds `subtle` as an optional dep under the `acp-mcp` feature. - acp_server route_client_response: accept a stringified-number JSON-RPC id ("1") in addition to a numeric id, so a spec-loose client's responses still correlate to their pending request instead of being silently dropped. Gate (targeted, no repo-wide fmt — this container's rustfmt disagrees with the branch on pre-existing import ordering): clippy clean on both crates; openab-core mcp_proxy tests 8/8, openab-gateway acp_server tests 35/35 green. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/Cargo.toml | 3 ++- crates/openab-core/src/mcp_proxy.rs | 7 ++++++- crates/openab-gateway/src/adapters/acp_server.rs | 7 ++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index 91c24f94b..dd0cc49ab 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -55,6 +55,7 @@ http = { version = "1", optional = true } rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true } axum = { version = "0.8", optional = true } tokio-util = { version = "0.7", optional = true } +subtle = { version = "2", optional = true } # constant-time bearer compare for the loopback MCP server [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -72,4 +73,4 @@ pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] # Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). -acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util"] +acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util", "dep:subtle"] diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 6ef0181af..2a2c4fd48 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -175,7 +175,12 @@ async fn require_bearer( .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) - .is_some_and(|t| t == &*expected); + // Constant-time compare so a wrong token can't be probed byte-by-byte via response + // timing (mirrors the gateway's feishu/wecom signature checks). + .is_some_and(|t| { + use subtle::ConstantTimeEq; + t.as_bytes().ct_eq(expected.as_bytes()).into() + }); if authed { next.run(req).await } else { diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 53320312a..d8cc14e34 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -524,7 +524,12 @@ async fn route_client_response( if has_method || !looks_like_response { return false; } - let Some(id) = raw.get("id").and_then(Value::as_u64) else { + // Accept a numeric id (what we mint) or a stringified number ("1") from a spec-loose + // client, so its responses still correlate to the pending request instead of being dropped. + let Some(id) = raw + .get("id") + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + else { return false; }; if let Some(tx) = pending.lock().await.remove(&id) { From 6476dae69b169af4cb2224d3515e46306fa42e87 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 06:31:48 +0800 Subject: [PATCH 24/46] fix(acp-mcp): establish the browser tunnel on session/resume, not just session/new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit katashiro persists its ACP session and RECONNECTS via session/resume (not session/new), re-declaring its "type":"acp" browser MCP server each time. The session/new branch spawns establish_and_register_tunnel for each declared server, but session/resume only recorded them in the session state and never opened a tunnel — so a resumed browser session had no entry in the tunnel registry and the core MCP proxy returned "no browser attached to session acp_" on every call. Mirror the session/new logic in the resume branch: derive the same deterministic channel_id from the sessionId and spawn establish_and_register_tunnel for each declared type:acp server. This is what makes the live loop work across katashiro's auto-reconnect (which always resumes). Gate: clippy clean, openab-gateway acp_server tests 35/35. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index d8cc14e34..348c4a3f0 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -949,6 +949,38 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } let resp = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // Re-open + register the browser tunnel(s) on resume too. katashiro persists its + // ACP session and RECONNECTS via session/resume (not session/new), re-declaring its + // "type":"acp" browser server each time. Without this, a resumed session records the + // server but never opens a tunnel, so the core proxy reports "no browser attached". + // channel_id is derived deterministically from the sessionId, matching the handler. + if let Some(registry) = state.acp_tunnel_registry.clone() { + if let Some(channel_id) = req + .params + .as_ref() + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()) + .and_then(derive_channel_id) + { + for srv in parse_acp_mcp_servers(req.params.as_ref()) { + let out_tx2 = out_tx.clone(); + let pending2 = pending_requests.clone(); + let next_id2 = next_req_id.clone(); + let channel = channel_id.clone(); + let reg = registry.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = establish_and_register_tunnel( + out_tx2, pending2, next_id2, srv.id, channel, reg, 30, + ) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel on resume"); + } + })); + } + } + } } "session/prompt" => { if !initialized { From dde1b349117e89b4e9376419b0b08f829d33101d Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 06:36:48 +0800 Subject: [PATCH 25/46] chore(acp-mcp): log browser tunnel open/register for live-session observability establish_and_register_tunnel is reached only when a client declared a "type":"acp" server, so an info line there answers "did the extension advertise itself?" from the gateway log alone (the raw upstream session frame isn't otherwise logged). Logs on open and on successful registry insert. Co-Authored-By: Claude Opus 4.8 --- crates/openab-gateway/src/adapters/acp_server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 348c4a3f0..8cfb084b2 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -712,6 +712,9 @@ async fn establish_and_register_tunnel( registry: AcpTunnelRegistry, timeout_secs: u64, ) -> Result<(), String> { + // Observability: reaching here means the client DID declare a "type":"acp" server, so this + // line in the log answers "did the browser extension advertise itself?" for a live session. + info!(acp_id = %acp_id, channel_id = %channel_id, "ACP: opening MCP-over-ACP browser tunnel"); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; let handle = TunnelHandle { out_tx, @@ -722,7 +725,8 @@ async fn establish_and_register_tunnel( registry .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id, handle); + .insert(channel_id.clone(), handle); + info!(channel_id = %channel_id, "ACP: browser tunnel registered — extension attached"); Ok(()) } From 06165f055c2a1b4d8ac9ec151355fde09ce0f336 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:03:51 +0800 Subject: [PATCH 26/46] =?UTF-8?q?fix(acp-mcp):=20raise=20ACP=20frame=20cap?= =?UTF-8?q?=201=E2=86=928=20MiB=20for=20browser-tool=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser tool results carried over the MCP-over-ACP tunnel (notably screenshots) routinely exceed the old 1 MiB inbound frame cap, which closed the WebSocket mid-response and wedged the extension in a reconnect loop. 8 MiB gives ample room for a compressed screenshot / large DOM snapshot while staying a sane DoS bound. Pairs with the katashiro-side switch to JPEG screenshots. Co-Authored-By: Claude Opus 4.8 --- crates/openab-gateway/src/adapters/acp_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 8cfb084b2..b76628a39 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -36,7 +36,7 @@ const ACP_PROTOCOL_VERSION: u32 = 1; /// eviction, and global connection/worker limits are a follow-up (review F6, roadmap). const MAX_SESSIONS_PER_CONNECTION: usize = 128; const MAX_INFLIGHT_PROMPTS: usize = 32; -const MAX_FRAME_BYTES: usize = 1 << 20; // 1 MiB per inbound JSON-RPC frame +const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB /// JSON-RPC implementation-defined server error for a hit resource cap. const ACP_OVERLOADED: i32 = -32000; From 1f5b0c9cee446e2a097b134a807fa34184f89da2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:53:19 +0800 Subject: [PATCH 27/46] =?UTF-8?q?fix(acp-mcp):=20one=20browser=20tunnel=20?= =?UTF-8?q?per=20session=20=E2=80=94=20fix=20fan-out=20overwrite/orphan=20?= =?UTF-8?q?(M-B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel registry is keyed by channel_id, and both the session/new and session/resume paths looped over every declared type:acp server calling establish_and_register_tunnel. With >1 server each insert overwrote the previous under the same channel_id, leaving the earlier tunnel opened-but-unreachable (orphaned). The core proxy only ever resolves a browser by channel_id, so one tunnel per session is the actual model. Factor both call sites into spawn_browser_tunnel(), which establishes only the first declared server and warns on extras. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 91 ++++++++++++------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index b76628a39..13c42456c 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -730,6 +730,47 @@ async fn establish_and_register_tunnel( Ok(()) } +/// Open + register the browser tunnel for a session's declared `type:acp` servers. +/// +/// Exactly ONE tunnel per session is supported: the core proxy resolves a browser by +/// `channel_id`, so registering a second server under the same `channel_id` would overwrite +/// the first in the registry and orphan its already-opened tunnel. We therefore establish only +/// the first declared server and warn if the client sent more. Spawned (not awaited inline) +/// because `establish_and_register_tunnel` awaits the client's `mcp/connect` response, which +/// only the read loop delivers — awaiting inline would deadlock. +#[allow(clippy::too_many_arguments)] +fn spawn_browser_tunnel( + servers: Vec, + channel_id: String, + registry: AcpTunnelRegistry, + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &Arc, + prompt_tasks: &mut Vec>, +) { + if servers.len() > 1 { + warn!( + channel_id = %channel_id, + count = servers.len(), + "ACP: multiple type:acp servers declared; only one browser tunnel per session is supported — using the first" + ); + } + let Some(srv) = servers.into_iter().next() else { + return; + }; + let out_tx = out_tx.clone(); + let pending = pending.clone(); + let next_id = next_id.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = + establish_and_register_tunnel(out_tx, pending, next_id, srv.id, channel_id, registry, 30) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); + } + })); +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -918,22 +959,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response // only THIS read loop delivers — awaiting inline would deadlock. if let Some(registry) = state.acp_tunnel_registry.clone() { - for srv in acp_mcp_servers { - let out_tx2 = out_tx.clone(); - let pending2 = pending_requests.clone(); - let next_id2 = next_req_id.clone(); - let channel = channel_id.clone(); - let reg = registry.clone(); - prompt_tasks.push(tokio::spawn(async move { - if let Err(e) = establish_and_register_tunnel( - out_tx2, pending2, next_id2, srv.id, channel, reg, 30, - ) - .await - { - warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); - } - })); - } + spawn_browser_tunnel( + acp_mcp_servers, + channel_id.clone(), + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); } } "session/resume" => { @@ -967,22 +1001,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { .and_then(|v| v.as_str()) .and_then(derive_channel_id) { - for srv in parse_acp_mcp_servers(req.params.as_ref()) { - let out_tx2 = out_tx.clone(); - let pending2 = pending_requests.clone(); - let next_id2 = next_req_id.clone(); - let channel = channel_id.clone(); - let reg = registry.clone(); - prompt_tasks.push(tokio::spawn(async move { - if let Err(e) = establish_and_register_tunnel( - out_tx2, pending2, next_id2, srv.id, channel, reg, 30, - ) - .await - { - warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel on resume"); - } - })); - } + spawn_browser_tunnel( + parse_acp_mcp_servers(req.params.as_ref()), + channel_id, + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); } } } From bbc9e13af5a88dd47b0a72a2f0dedf1b070c22b7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:53:19 +0800 Subject: [PATCH 28/46] fix(acp-mcp): 0600 mcp.json + strip stale bearer on evict (M-B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_session_server wrote /.cursor/mcp.json with tokio::fs::write, leaving it at the umask default (typically 0644) — but the file embeds the live loopback bearer token, so any local user could read it. Write via write_private() which chmods it 0600. Also, on session evict (CancellationToken fires) strip the now-dead openab-browser entry so a stale credential doesn't linger; guarded to only remove the entry if it still points at our addr, so a concurrent/reconnected session that already replaced it isn't clobbered (the mcp.json path is shared across acp: sessions). Adds a 0600 assertion to the existing config-write test. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 57 ++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 2a2c4fd48..c576a2ff9 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -251,15 +251,58 @@ pub async fn start_session_server( if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { cfg["mcpServers"] = json!({}); } + let our_url = format!("http://{addr}/mcp"); cfg["mcpServers"]["openab-browser"] = json!({ - "url": format!("http://{addr}/mcp"), + "url": our_url, "headers": { "Authorization": format!("Bearer {bearer}") } }); - tokio::fs::write(&cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + // 0600: the file carries a live bearer token — default umask would leave it world-readable. + write_private(&cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + + // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry + // (with its live bearer) so a stale credential doesn't linger. Only remove it if it still + // points at OUR addr — a concurrent/reconnected session may have already replaced it, and we + // must not clobber that live entry (the mcp.json path is shared across acp: sessions). + let cleanup_path = cfg_path.clone(); + let cleanup_ct = ct.clone(); + tokio::spawn(async move { + cleanup_ct.cancelled().await; + let Ok(bytes) = tokio::fs::read(&cleanup_path).await else { + return; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + return; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(our_url.as_str()); + if !still_ours { + return; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(&cleanup_path, &out).await; + } + }); Ok((addr, ct)) } +/// Write `bytes` to `path`, then tighten it to owner-only (0600). The file holds a live bearer +/// token for the loopback MCP server, so it must not be group/world readable. +async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + tokio::fs::write(path, bytes).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{browser_tools, spawn_mcp_server, start_session_server, BrowserTunnel, ProxyHandler}; @@ -313,6 +356,16 @@ mod tests { .as_str() .unwrap() .starts_with("Bearer ")); + // The file holds a live bearer — it must be owner-only (0600), not umask-default 0644. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(".cursor/mcp.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "mcp.json (live bearer) must be 0600"); + } ct.cancel(); } From b9bdd087ae58ebd34b062d44c419324e22d7f777 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:55:15 +0800 Subject: [PATCH 29/46] chore(acp-mcp): lock subtle dependency Cargo.lock was missing the openab-core `subtle` entry added for the constant-time bearer compare, which would fail a `--locked` build. No code change. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 970462b42..50855f6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2264,6 +2264,7 @@ dependencies = [ "serde_json", "serenity", "sha2 0.10.9", + "subtle", "tar", "tempfile", "tokio", From a6d9654100d0be3534e554dc2d5dd9a1508fa1fa Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 14:42:32 +0800 Subject: [PATCH 30/46] feat(mcp-proxy): write openab-browser into kiro-cli config too, not just Cursor start_session_server now merges the openab-browser entry into BOTH .cursor/mcp.json and .kiro/settings/mcp.json (each CLI ignores the other's), and cleans both on evict. kiro-cli parses the {url, headers} shape identically. Deployed as acpmcp-kirofix. Also add .dockerignore (exclude target/, .git/, data/) for the acp image builds. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 4 ++ crates/openab-core/src/mcp_proxy.rs | 92 +++++++++++++++++------------ 2 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..cb222e6ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target/ +.git/ +data/ +*.tgz diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c576a2ff9..e06329544 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -239,52 +239,68 @@ pub async fn start_session_server( let ct = tokio_util::sync::CancellationToken::new(); let addr = spawn_mcp_server(channel_id.to_string(), tunnel, bearer.clone(), ct.clone()).await?; - // Merge the openab-browser entry into /.cursor/mcp.json (don't clobber any - // servers the user/agent already configured). - let cursor_dir = std::path::Path::new(workdir).join(".cursor"); - tokio::fs::create_dir_all(&cursor_dir).await?; - let cfg_path = cursor_dir.join("mcp.json"); - let mut cfg: Value = match tokio::fs::read(&cfg_path).await { - Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), - Err(_) => json!({}), - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } let our_url = format!("http://{addr}/mcp"); - cfg["mcpServers"]["openab-browser"] = json!({ - "url": our_url, + let entry = json!({ + "url": our_url.clone(), "headers": { "Authorization": format!("Bearer {bearer}") } }); - // 0600: the file carries a live bearer token — default umask would leave it world-readable. - write_private(&cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + + // Merge the openab-browser entry into each colocated ACP CLI's native MCP config (don't + // clobber servers the user/agent already configured). Cursor reads /.cursor/mcp.json; + // kiro-cli reads /.kiro/settings/mcp.json. We write both — each CLI ignores the + // other's file — so the browser server reaches whichever agent is colocated. + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + cfg["mcpServers"]["openab-browser"] = entry.clone(); + // 0600: the file carries a live bearer token — default umask would leave it world-readable. + write_private(cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + } // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry - // (with its live bearer) so a stale credential doesn't linger. Only remove it if it still - // points at OUR addr — a concurrent/reconnected session may have already replaced it, and we - // must not clobber that live entry (the mcp.json path is shared across acp: sessions). - let cleanup_path = cfg_path.clone(); + // (with its live bearer) from each config so a stale credential doesn't linger. Only remove it + // if it still points at OUR addr — a concurrent/reconnected session may have already replaced + // it, and we must not clobber that live entry (the mcp.json paths are shared across acp: sessions). + let cleanup_paths = cfg_paths.to_vec(); + let cleanup_url = our_url; let cleanup_ct = ct.clone(); tokio::spawn(async move { cleanup_ct.cancelled().await; - let Ok(bytes) = tokio::fs::read(&cleanup_path).await else { - return; - }; - let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { - return; - }; - let still_ours = cfg - .pointer("/mcpServers/openab-browser/url") - .and_then(Value::as_str) - == Some(our_url.as_str()); - if !still_ours { - return; - } - if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { - servers.remove("openab-browser"); - } - if let Ok(out) = serde_json::to_vec_pretty(&cfg) { - let _ = write_private(&cleanup_path, &out).await; + for cleanup_path in &cleanup_paths { + let Ok(bytes) = tokio::fs::read(cleanup_path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(cleanup_url.as_str()); + if !still_ours { + continue; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(cleanup_path, &out).await; + } } }); From dc0009a4a4f59726a5f1e4dd67ea4f92860f91c7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 14:42:32 +0800 Subject: [PATCH 31/46] =?UTF-8?q?docs:=20browser=20MCP=20agent=20setup=20?= =?UTF-8?q?=E2=80=94=20per-variant=20mcp.json=20how-to=20(Phase=202=20#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit How the openab-browser tools reach each agent CLI: the per-session loopback proxy + where openab writes the {url, headers} entry per variant (Cursor/Kiro auto today; Claude/Codex/Gemini paths documented, not yet auto). Honest caveat: static manual config awaits the stable-endpoint redesign (#9). Co-Authored-By: Claude Opus 4.8 --- docs/browser-mcp-agent-setup.md | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/browser-mcp-agent-setup.md diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md new file mode 100644 index 000000000..bcf3ec389 --- /dev/null +++ b/docs/browser-mcp-agent-setup.md @@ -0,0 +1,73 @@ +# Browser MCP — how the agent gets the `openab-browser` tools + +The browser MCP server exposes five DOM-semantic tools — +`browser.read_dom`, `browser.screenshot`, `browser.navigate`, `browser.click`, `browser.type` — +served by the **browser extension** over the MCP-over-ACP tunnel (see +[tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the +colocated agent CLI actually **sees** those tools. + +## How it reaches the agent + +The gateway↔extension tunnel terminates in the pod at a **per-session loopback MCP proxy** +(`openab-core` `mcp_proxy::start_session_server`). To expose it to the agent, openab writes an +`openab-browser` entry into the agent CLI's **native MCP config file** — the agent connects to +`http://127.0.0.1:/mcp` and re-lists the tools. The entry looks like: + +```json +{ + "mcpServers": { + "openab-browser": { + "url": "http://127.0.0.1:/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Both `` and `` are **minted fresh per session** and the entry is stripped on session +evict — so this is written by openab, not hand-editable to a fixed value (see *Caveat* below). + +## Per-variant MCP config location + +openab writes the same entry into whichever file the colocated CLI reads. Current state: + +| Variant | MCP config file (under `$workdir`, = `$HOME`) | HTTP MCP + `headers` | Auto-written by openab today | +|---|---|---|---| +| **Cursor** (`cursor-agent`) | `.cursor/mcp.json` | yes | ✅ yes | +| **Kiro** (`kiro-cli`) | `.kiro/settings/mcp.json` | yes | ✅ yes | +| **Claude Code** | `.mcp.json` / `~/.claude.json` `mcpServers` | yes | ⛔ not yet | +| **Codex** | `~/.codex/config.toml` `[mcp_servers.*]` (TOML) | check version | ⛔ not yet | +| **Gemini CLI** | `~/.gemini/settings.json` `mcpServers` | yes | ⛔ not yet | + +`start_session_server` currently writes **`.cursor/mcp.json` + `.kiro/settings/mcp.json`**. Adding +a variant = teach that function the CLI's config path + format (same `{url, headers}` shape for +JSON configs; Codex uses TOML and needs a small serializer). + +## Manual / unsupported variants + +If a variant isn't auto-written, a user *could* add the `openab-browser` entry to that CLI's +mcp.json by hand — **but** the current proxy endpoint is per-session ephemeral (fresh port + +bearer each session), so a static hand-written entry goes stale on the next session and cannot +be used as-is. Manual configuration for arbitrary variants is therefore gated on a **stable +browser-MCP endpoint** (a fixed URL + stable auth the user configures once). That redesign is +tracked separately (see `drafts/` browser-MCP stable-endpoint design). Until then: + +- **Cursor / Kiro:** work out of the box (auto-injected). +- **Other variants:** either add the variant's writer to `start_session_server`, or wait for the + stable endpoint. + +## Verify + +Inside the agent pod, after the extension attaches a browser session: + +```sh +# the entry openab wrote for this session +cat "$HOME/.cursor/mcp.json" # Cursor +cat "$HOME/.kiro/settings/mcp.json" # Kiro + +# does the CLI see the server / tools? (CLI-specific; e.g. Kiro:) +kiro-cli mcp list +``` + +Gateway log confirms the extension side: +`ACP: browser tunnel registered — extension attached`. From c2bd77d2401bb4bbbd1c71ad8e1c0096294ecfc7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 18:38:08 +0800 Subject: [PATCH 32/46] feat(mcp-proxy): per-pod browser-bridge socket server (Option C, P1) serve_browser_socket: one unix socket multiplexes all sessions; the openab browser-bridge shim forwards {channel_id, inner MCP request} frames, routed via dispatch_browser_mcp -> the shared BrowserTunnel by channel. Reuses browser_tools() + tunnel.call (single source of truth vs the HTTP ProxyHandler). +8 tests. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 275 +++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index e06329544..6b4797589 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -319,9 +319,138 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } +// ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- +// A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim +// (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests +// tagged with its own `channel_id` (from the OPENAB_BROWSER_CHANNEL env it inherits); core routes +// `tools/call` to that session's BrowserTunnel. This is the stable, variant-agnostic replacement +// for the per-session HTTP proxy (Option C). Wire = newline-delimited JSON, one frame per line: +// bridge → core : {"channel_id": "...", "request": } +// core → bridge : (omitted for notifications) + +const BROWSER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +fn mcp_result(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn mcp_error(id: Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +/// Dispatch one inner MCP request for `channel_id`, backing `tools/call` with the shared browser +/// tunnel. Returns the MCP response, or `None` for a JSON-RPC notification (no reply). Same tool +/// set + not-connected semantics as the HTTP `ProxyHandler` (single source of truth). +pub(crate) async fn dispatch_browser_mcp( + channel_id: &str, + request: &Value, + tunnel: &Option>, +) -> Option { + // A JSON-RPC notification has no `id` → no response. + let id = request.get("id").cloned()?; + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let resp = match method { + "initialize" => mcp_result( + id, + json!({ + "protocolVersion": BROWSER_MCP_PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": "openab-browser", "version": env!("CARGO_PKG_VERSION") } + }), + ), + "tools/list" => { + let tools = serde_json::to_value(browser_tools()).unwrap_or_else(|_| json!([])); + mcp_result(id, json!({ "tools": tools })) + } + "tools/call" => match tunnel { + Some(t) => match t + .call(channel_id, "tools/call", request.get("params").cloned()) + .await + { + Ok(v) => mcp_result(id, v), + Err(e) => mcp_error(id, -32603, &e), + }, + None => mcp_error( + id, + -32603, + "browser not connected: open the OpenAB side panel in your browser", + ), + }, + other => mcp_error(id, -32601, &format!("method not found: {other}")), + }; + Some(resp) +} + +/// Serve the per-pod browser-bridge socket at `path`, routing each connection's framed requests +/// via [`dispatch_browser_mcp`]. Binds a fresh 0600 unix socket (same-uid only), spawns the accept +/// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. +pub async fn serve_browser_socket( + path: std::path::PathBuf, + tunnel: Option>, + ct: tokio_util::sync::CancellationToken, +) -> std::io::Result<()> { + let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run + let listener = tokio::net::UnixListener::bind(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + tokio::spawn(async move { + loop { + tokio::select! { + _ = ct.cancelled() => break, + accepted = listener.accept() => { + match accepted { + Ok((stream, _)) => { + tokio::spawn(handle_browser_conn(stream, tunnel.clone())); + } + Err(_) => continue, + } + } + } + } + let _ = tokio::fs::remove_file(&path).await; + }); + Ok(()) +} + +async fn handle_browser_conn( + stream: tokio::net::UnixStream, + tunnel: Option>, +) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let Ok(frame) = serde_json::from_str::(&line) else { + continue; // skip a malformed frame rather than drop the connection + }; + let channel_id = frame.get("channel_id").and_then(Value::as_str).unwrap_or(""); + let Some(request) = frame.get("request") else { + continue; + }; + if let Some(resp) = dispatch_browser_mcp(channel_id, request, &tunnel).await { + let Ok(mut buf) = serde_json::to_vec(&resp) else { + continue; + }; + buf.push(b'\n'); + if write_half.write_all(&buf).await.is_err() { + break; + } + } + } +} + #[cfg(test)] mod tests { - use super::{browser_tools, spawn_mcp_server, start_session_server, BrowserTunnel, ProxyHandler}; + use super::{ + browser_tools, dispatch_browser_mcp, serve_browser_socket, spawn_mcp_server, + start_session_server, BrowserTunnel, ProxyHandler, + }; struct MockTunnel; #[async_trait::async_trait] @@ -355,6 +484,150 @@ mod tests { ); } + // --- Option C: browser-bridge socket dispatch --- + struct RecordTunnel { + result: serde_json::Value, + } + #[async_trait::async_trait] + impl BrowserTunnel for RecordTunnel { + async fn call( + &self, + channel_id: &str, + method: &str, + _params: Option, + ) -> Result { + assert_eq!(method, "tools/call"); + assert_eq!(channel_id, "acp_win1"); + Ok(self.result.clone()) + } + } + struct ErrTunnel; + #[async_trait::async_trait] + impl BrowserTunnel for ErrTunnel { + async fn call( + &self, + _c: &str, + _m: &str, + _p: Option, + ) -> Result { + Err("no browser attached".into()) + } + } + fn req(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + } + fn arc_tunnel(t: T) -> Option> { + Some(std::sync::Arc::new(t)) + } + + #[tokio::test] + async fn dispatch_initialize_advertises_tools() { + let r = dispatch_browser_mcp("acp_x", &req(1, "initialize", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["id"], 1); + assert_eq!(r["result"]["capabilities"]["tools"], serde_json::json!({})); + assert_eq!(r["result"]["serverInfo"]["name"], "openab-browser"); + } + + #[tokio::test] + async fn dispatch_tools_list_returns_five_tools() { + let r = dispatch_browser_mcp("acp_x", &req(2, "tools/list", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 5); + } + + #[tokio::test] + async fn dispatch_tools_call_routes_to_the_channel_tunnel() { + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let r = dispatch_browser_mcp( + "acp_win1", + &req(3, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })), + &tunnel, + ) + .await + .unwrap(); + assert_eq!(r["result"]["content"][0]["text"], "ok"); + } + + #[tokio::test] + async fn dispatch_tools_call_without_tunnel_is_not_connected() { + let r = dispatch_browser_mcp( + "acp_x", + &req(4, "tools/call", serde_json::json!({ "name": "browser.click" })), + &None, + ) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32603); + assert!(r["error"]["message"].as_str().unwrap().contains("not connected")); + } + + #[tokio::test] + async fn dispatch_tools_call_surfaces_tunnel_error() { + let tunnel = arc_tunnel(ErrTunnel); + let r = dispatch_browser_mcp( + "acp_x", + &req(5, "tools/call", serde_json::json!({ "name": "browser.click" })), + &tunnel, + ) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32603); + assert_eq!(r["error"]["message"], "no browser attached"); + } + + #[tokio::test] + async fn dispatch_notification_gets_no_response() { + let notif = serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + assert!(dispatch_browser_mcp("acp_x", ¬if, &None).await.is_none()); + } + + #[tokio::test] + async fn dispatch_unknown_method_is_method_not_found() { + let r = dispatch_browser_mcp("acp_x", &req(6, "bogus/thing", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32601); + } + + #[tokio::test] + async fn browser_socket_round_trip() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("browser.sock"); + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let ct = tokio_util::sync::CancellationToken::new(); + serve_browser_socket(sock.clone(), tunnel, ct.clone()) + .await + .unwrap(); + let stream = loop { + match tokio::net::UnixStream::connect(&sock).await { + Ok(s) => break s, + Err(_) => tokio::task::yield_now().await, + } + }; + let (rd, mut wr) = stream.into_split(); + let frame = serde_json::json!({ + "channel_id": "acp_win1", + "request": req(9, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })) + }); + let mut line = serde_json::to_vec(&frame).unwrap(); + line.push(b'\n'); + wr.write_all(&line).await.unwrap(); + let mut resp = String::new(); + BufReader::new(rd).read_line(&mut resp).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!(v["id"], 9); + assert_eq!(v["result"]["content"][0]["text"], "ok"); + ct.cancel(); + } + #[tokio::test] async fn start_session_server_writes_cursor_config() { let dir = tempfile::tempdir().unwrap(); From 73a777e8c92c65ed2c704f74622f8c4042a6b38c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 18:59:26 +0800 Subject: [PATCH 33/46] =?UTF-8?q?feat(cli):=20openab=20browser-bridge=20su?= =?UTF-8?q?bcommand=20=E2=80=94=20stdio=20MCP=20relay=20to=20the=20browser?= =?UTF-8?q?=20socket=20(Option=20C,=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin per-session shim: reads OPENAB_BROWSER_CHANNEL, wraps each stdin MCP request as {channel_id, request}, forwards to the per-pod core socket, relays responses to stdout verbatim. All browser MCP logic stays in core; the agent's config line is static. Gated by feature acp. + wrap/relay tests over in-memory pipes. Co-Authored-By: Claude Opus 4.8 --- src/browser_bridge.rs | 165 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 11 +++ 2 files changed, 176 insertions(+) create mode 100644 src/browser_bridge.rs diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs new file mode 100644 index 000000000..39aa96a6d --- /dev/null +++ b/src/browser_bridge.rs @@ -0,0 +1,165 @@ +//! `openab browser-bridge` — a stdio MCP server that is a thin relay to the per-pod browser +//! socket (Option C). The agent's MCP client spawns it per session; it reads +//! `OPENAB_BROWSER_CHANNEL` from its inherited env, wraps each stdin MCP request as +//! `{channel_id, request}`, forwards it to the core socket, and relays responses to stdout +//! verbatim. ALL browser MCP logic lives in core (`dispatch_browser_mcp`) — this is a pure pipe +//! + channel tag, so the config line agents carry is static (`{"command":"openab","args": +//! ["browser-bridge"]}`) and disambiguation is by the inherited env, never by config. +//! +//! stdout carries the MCP wire, so this path emits nothing to stdout except MCP responses +//! (diagnostics, if any, go to stderr). + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Per-pod socket path; overridable via `OPENAB_BROWSER_SOCKET`. +fn socket_path() -> std::path::PathBuf { + if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { + return p.into(); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); + std::path::Path::new(&home).join(".openab").join("browser.sock") +} + +/// Wrap one stdin MCP request line into a socket frame `{channel_id, request}`. Returns `None` +/// for a blank/unparseable line (skip it) so a stray line can't break the relay. +fn wrap_frame(channel: &str, line: &str) -> Option> { + let line = line.trim(); + if line.is_empty() { + return None; + } + let request: Value = serde_json::from_str(line).ok()?; + let frame = json!({ "channel_id": channel, "request": request }); + let mut buf = serde_json::to_vec(&frame).ok()?; + buf.push(b'\n'); + Some(buf) +} + +/// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and +/// socket→stdout (verbatim MCP responses) until either side closes. +pub async fn run() -> std::io::Result<()> { + let channel = std::env::var("OPENAB_BROWSER_CHANNEL").unwrap_or_default(); + let sock = tokio::net::UnixStream::connect(socket_path()).await?; + let (sock_rd, sock_wr) = sock.into_split(); + pump( + channel, + BufReader::new(tokio::io::stdin()), + tokio::io::stdout(), + BufReader::new(sock_rd), + sock_wr, + ) + .await +} + +/// The relay, generic over the four streams so it can be tested with in-memory pipes. Ends when +/// either stdin (agent gone) or the socket (core gone) closes. +async fn pump( + channel: String, + mut stdin: In, + mut stdout: Out, + mut sock_rd: SockR, + mut sock_wr: SockW, +) -> std::io::Result<()> +where + In: AsyncBufReadExt + Unpin, + Out: AsyncWriteExt + Unpin, + SockR: AsyncBufReadExt + Unpin, + SockW: AsyncWriteExt + Unpin, +{ + let to_sock = async { + let mut line = String::new(); + loop { + line.clear(); + if stdin.read_line(&mut line).await? == 0 { + break; // stdin closed → agent gone + } + if let Some(frame) = wrap_frame(&channel, &line) { + sock_wr.write_all(&frame).await?; + sock_wr.flush().await?; + } + } + Ok::<(), std::io::Error>(()) + }; + let to_stdout = async { + let mut line = String::new(); + loop { + line.clear(); + if sock_rd.read_line(&mut line).await? == 0 { + break; // socket closed → core gone + } + stdout.write_all(line.as_bytes()).await?; + stdout.flush().await?; + } + Ok::<(), std::io::Error>(()) + }; + // Whichever side closes first ends the relay; the other pump is dropped (we're shutting down). + tokio::select! { + r = to_sock => r, + r = to_stdout => r, + } +} + +#[cfg(test)] +mod tests { + use super::{pump, wrap_frame}; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + #[test] + fn wrap_frame_tags_the_channel_and_appends_newline() { + let out = wrap_frame("acp_win1", r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).unwrap(); + assert_eq!(*out.last().unwrap(), b'\n'); + let v: serde_json::Value = serde_json::from_slice(&out[..out.len() - 1]).unwrap(); + assert_eq!(v["channel_id"], "acp_win1"); + assert_eq!(v["request"]["method"], "tools/list"); + assert_eq!(v["request"]["id"], 1); + } + + #[test] + fn wrap_frame_skips_blank_and_malformed_lines() { + assert!(wrap_frame("c", " ").is_none()); + assert!(wrap_frame("c", "").is_none()); + assert!(wrap_frame("c", "not json").is_none()); + } + + #[tokio::test] + async fn pump_relays_request_to_socket_and_response_to_stdout() { + // Four in-memory pipes standing in for stdin, stdout, and the two socket halves. + let (mut stdin_w, stdin_r) = tokio::io::duplex(1024); + let (stdout_w, mut stdout_r) = tokio::io::duplex(1024); + let (mut sock_peer_w, sock_rd) = tokio::io::duplex(1024); // core → bridge (responses) + let (sock_wr, mut sock_peer_r) = tokio::io::duplex(1024); // bridge → core (frames) + + let handle = tokio::spawn(pump( + "acp_win1".to_string(), + BufReader::new(stdin_r), + stdout_w, + BufReader::new(sock_rd), + sock_wr, + )); + + // Agent writes an MCP request on stdin → bridge should emit a channel-tagged frame to core. + stdin_w + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{}}\n") + .await + .unwrap(); + let mut frame = String::new(); + BufReader::new(&mut sock_peer_r).read_line(&mut frame).await.unwrap(); + let fv: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(fv["channel_id"], "acp_win1"); + assert_eq!(fv["request"]["id"], 9); + + // Core writes an MCP response on the socket → bridge should relay it verbatim to stdout. + sock_peer_w + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{\"ok\":true}}\n") + .await + .unwrap(); + let mut out = String::new(); + BufReader::new(&mut stdout_r).read_line(&mut out).await.unwrap(); + let ov: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(ov["id"], 9); + assert_eq!(ov["result"]["ok"], true); + + drop(stdin_w); // agent gone → relay ends + let _ = handle.await; + } +} diff --git a/src/main.rs b/src/main.rs index 534a3e467..3da07bbbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ mod ctl; mod unified_adapter; #[cfg(feature = "acp")] mod browser_tunnel; +#[cfg(feature = "acp")] +mod browser_bridge; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -101,6 +103,11 @@ enum Commands { #[arg(long, default_value = "kiro-cli acp --trust-all-tools")] command: String, }, + /// Internal: stdio MCP bridge to the per-pod browser socket (Option C). Spawned per session + /// by the agent's MCP client; relays MCP over stdio to core's browser tunnel by inherited + /// OPENAB_BROWSER_CHANNEL. + #[cfg(feature = "acp")] + BrowserBridge, /// Set a runtime value (e.g. thread.name) Set { /// Key to set (e.g. thread.name) @@ -271,6 +278,10 @@ async fn main() -> anyhow::Result<()> { } => { return acp::agentcore::run_bridge(&runtime_arn, ®ion, &command).await; } + #[cfg(feature = "acp")] + Commands::BrowserBridge => { + return browser_bridge::run().await.map_err(Into::into); + } Commands::Set { key, value, thread } => { let resp = ctl::send_request(&ctl::Request { action: ctl::Action::Set, From 8b18c481266a778d41eb337b95f0ddffb81f60df Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 19:22:04 +0800 Subject: [PATCH 34/46] feat(acp): inject OPENAB_BROWSER_CHANNEL into the agent env (Option C, P3) AcpConnection::spawn gains a browser_channel param; for an acp: session the pool passes the channel_id so the agent (and the browser-bridge shim it later spawns) inherits it and routes browser tool calls to THIS session's tunnel. env_clear-safe (re-injected explicitly). + set_browser_channel unit tests. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/connection.rs | 35 +++++++++++++++++++++++- crates/openab-core/src/acp/pool.rs | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index cb9577ddd..4ff4a4255 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -336,6 +336,7 @@ impl AcpConnection { working_dir: &str, env: &std::collections::HashMap, inherit_env: &[String], + browser_channel: Option<&str>, ) -> Result { info!(cmd = command, ?args, cwd = working_dir, "spawning agent"); @@ -402,6 +403,10 @@ impl AcpConnection { cmd.env("SystemDrive", v); } } + // Option C: hand this session's browser channel to the agent so the `openab + // browser-bridge` stdio shim it later spawns inherits it and routes tool calls to THIS + // session's tunnel. env_clear() above dropped it, so (re)inject explicitly. + set_browser_channel(&mut cmd, browser_channel); for (k, v) in env { cmd.env(k, expand_env(v)); } @@ -847,11 +852,39 @@ impl Drop for AcpConnection { } } +/// Inject the session's browser channel into the agent's env so the `openab browser-bridge` +/// stdio shim the agent later spawns inherits it (Option C) and routes tool calls to THIS +/// session's tunnel. No-op for non-browser sessions (`None`). +fn set_browser_channel(cmd: &mut tokio::process::Command, browser_channel: Option<&str>) { + if let Some(channel) = browser_channel { + cmd.env("OPENAB_BROWSER_CHANNEL", channel); + } +} + #[cfg(test)] mod tests { - use super::{build_agent_env, build_permission_response, pick_best_option}; + use super::{build_agent_env, build_permission_response, pick_best_option, set_browser_channel}; use serde_json::json; + #[test] + fn set_browser_channel_injects_env_when_present() { + let mut cmd = tokio::process::Command::new("true"); + set_browser_channel(&mut cmd, Some("acp_win1")); + assert!(cmd.as_std().get_envs().any(|(k, v)| { + k == "OPENAB_BROWSER_CHANNEL" && v == Some(std::ffi::OsStr::new("acp_win1")) + })); + } + + #[test] + fn set_browser_channel_is_noop_when_absent() { + let mut cmd = tokio::process::Command::new("true"); + set_browser_channel(&mut cmd, None); + assert!(!cmd + .as_std() + .get_envs() + .any(|(k, _)| k == "OPENAB_BROWSER_CHANNEL")); + } + #[test] fn picks_allow_always_over_other_options() { let options = vec![ diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index e1e129116..adbbc59b4 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -398,6 +398,7 @@ impl SessionPool { &effective_workdir, &self.config.env, &self.config.inherit_env, + thread_id.strip_prefix("acp:"), ) .await?; From 2509bde03afacbc3a2c31f76f8464d9e6ef45202 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 19:33:34 +0800 Subject: [PATCH 35/46] feat(mcp-proxy): static write-once browser-bridge config (Option C, P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_bridge_mcp_config writes the SAME {command:openab, args:[browser-bridge]} entry to cursor + kiro mcp.json — no port/bearer, so it never goes stale and can't clobber across sessions (the root cause of multi-window browser flakiness). Merges without touching the user's servers; idempotent. Additive — P5 wires the proxy/bridge toggle. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 73 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 6b4797589..4a335a4ff 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -319,6 +319,41 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } +/// Write the STATIC, write-once `openab-browser` bridge entry into each colocated CLI's mcp.json +/// (Option C, bridge mode). Unlike the per-session HTTP proxy config, this carries no port/bearer +/// — it is the same `{command:"openab", args:["browser-bridge"]}` for every session, so it can be +/// written once and never goes stale. That fixes the shared-config clobber the per-session dynamic +/// write suffers when several sessions of one agent share a single mcp.json. Merges without +/// touching the user's other servers; idempotent (a no-op when already present + identical). +pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { + let entry = json!({ "command": "openab", "args": ["browser-bridge"] }); + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + // Idempotent: only rewrite when absent or changed (no needless mtime churn each session). + if cfg["mcpServers"]["openab-browser"] != entry { + cfg["mcpServers"]["openab-browser"] = entry.clone(); + tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + // ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- // A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim // (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests @@ -449,7 +484,7 @@ async fn handle_browser_conn( mod tests { use super::{ browser_tools, dispatch_browser_mcp, serve_browser_socket, spawn_mcp_server, - start_session_server, BrowserTunnel, ProxyHandler, + start_session_server, write_bridge_mcp_config, BrowserTunnel, ProxyHandler, }; struct MockTunnel; @@ -594,6 +629,42 @@ mod tests { assert_eq!(r["error"]["code"], -32601); } + #[tokio::test] + async fn write_bridge_config_writes_static_entry_to_both_variants() { + let dir = tempfile::tempdir().unwrap(); + write_bridge_mcp_config(dir.path().to_str().unwrap()) + .await + .unwrap(); + for rel in [".cursor/mcp.json", ".kiro/settings/mcp.json"] { + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join(rel)).unwrap()).unwrap(); + let e = &cfg["mcpServers"]["openab-browser"]; + assert_eq!(e["command"], "openab"); + assert_eq!(e["args"], serde_json::json!(["browser-bridge"])); + assert!(e.get("url").is_none(), "bridge entry carries no url/port"); + assert!(e.get("headers").is_none(), "bridge entry carries no bearer"); + } + } + + #[tokio::test] + async fn write_bridge_config_merges_without_clobber_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, + ) + .unwrap(); + let wd = dir.path().to_str().unwrap(); + write_bridge_mcp_config(wd).await.unwrap(); + write_bridge_mcp_config(wd).await.unwrap(); // idempotent second call + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert_eq!(cfg["mcpServers"]["other"]["url"], "http://x"); // user's server preserved + assert_eq!(cfg["mcpServers"]["openab-browser"]["command"], "openab"); + } + #[tokio::test] async fn browser_socket_round_trip() { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; From 822fdf91bf4c4af4b3ddff8abedf89aea490df07 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 19:52:34 +0800 Subject: [PATCH 36/46] feat: OPENAB_BROWSER_MODE proxy|bridge toggle wiring (Option C, P5) BrowserMode + browser_mode() (default proxy) + shared browser_socket_path(). Pool branches: proxy = per-session HTTP server (unchanged default); bridge = static write-once config, no per-session server. Broker starts the per-pod socket server once in bridge mode. browser-bridge shim uses the shared socket path. + parse tests. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/pool.rs | 42 +++++++++++++------ crates/openab-core/src/mcp_proxy.rs | 64 ++++++++++++++++++++++++++++- src/browser_bridge.rs | 12 +----- src/main.rs | 21 +++++++++- 4 files changed, 112 insertions(+), 27 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index adbbc59b4..256769280 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -370,21 +370,37 @@ impl SessionPool { #[cfg(feature = "acp-mcp")] let mcp_guard: Option = if let Some(channel_id) = thread_id.strip_prefix("acp:") { - match crate::mcp_proxy::start_session_server( - channel_id, - &effective_workdir, - self.browser_tunnel.clone(), - ) - .await - { - Ok((addr, ct)) => { - info!(thread_id, %addr, "started per-session MCP proxy server"); - Some(ct.drop_guard()) - } - Err(e) => { - warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + match crate::mcp_proxy::browser_mode() { + // Bridge mode (Option C): the agent's static mcp.json points at `openab + // browser-bridge`, which dials the pod-wide socket server (started at boot). + // Just ensure the write-once config exists — no per-session server/guard. + crate::mcp_proxy::BrowserMode::Bridge => { + if let Err(e) = + crate::mcp_proxy::write_bridge_mcp_config(&effective_workdir).await + { + warn!(thread_id, error = %e, "failed to write bridge mcp config"); + } None } + // Proxy mode (default): per-session loopback HTTP MCP server + dynamic config. + crate::mcp_proxy::BrowserMode::Proxy => { + match crate::mcp_proxy::start_session_server( + channel_id, + &effective_workdir, + self.browser_tunnel.clone(), + ) + .await + { + Ok((addr, ct)) => { + info!(thread_id, %addr, "started per-session MCP proxy server"); + Some(ct.drop_guard()) + } + Err(e) => { + warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + None + } + } + } } } else { None diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 4a335a4ff..3c5d2c34d 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -354,6 +354,43 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { Ok(()) } +/// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into +/// the stdio bridge; anything else (including unset) keeps the per-session HTTP proxy — the safe +/// default during rollout, so existing Cursor/Kiro browser control is unchanged until flipped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserMode { + Proxy, + Bridge, +} + +impl BrowserMode { + pub fn is_bridge(self) -> bool { + matches!(self, BrowserMode::Bridge) + } +} + +fn parse_browser_mode(s: Option<&str>) -> BrowserMode { + match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("bridge") => BrowserMode::Bridge, + _ => BrowserMode::Proxy, + } +} + +/// Read the browser transport mode from `OPENAB_BROWSER_MODE` (default: proxy). +pub fn browser_mode() -> BrowserMode { + parse_browser_mode(std::env::var("OPENAB_BROWSER_MODE").ok().as_deref()) +} + +/// Per-pod browser-bridge socket path (overridable via `OPENAB_BROWSER_SOCKET`). Single source of +/// truth shared by the core socket server and the `openab browser-bridge` shim so they agree. +pub fn browser_socket_path() -> std::path::PathBuf { + if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { + return p.into(); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); + std::path::Path::new(&home).join(".openab").join("browser.sock") +} + // ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- // A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim // (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests @@ -450,6 +487,16 @@ pub async fn serve_browser_socket( Ok(()) } +/// Start the browser socket for the process lifetime (no external cancellation handle) — used by +/// the broker in bridge mode. The pod-wide server lives as long as the process, so no caller-side +/// tokio-util dependency is needed. +pub async fn serve_browser_socket_forever( + path: std::path::PathBuf, + tunnel: Option>, +) -> std::io::Result<()> { + serve_browser_socket(path, tunnel, tokio_util::sync::CancellationToken::new()).await +} + async fn handle_browser_conn( stream: tokio::net::UnixStream, tunnel: Option>, @@ -483,10 +530,23 @@ async fn handle_browser_conn( #[cfg(test)] mod tests { use super::{ - browser_tools, dispatch_browser_mcp, serve_browser_socket, spawn_mcp_server, - start_session_server, write_bridge_mcp_config, BrowserTunnel, ProxyHandler, + browser_tools, dispatch_browser_mcp, parse_browser_mode, serve_browser_socket, + spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, BrowserTunnel, + ProxyHandler, }; + #[test] + fn browser_mode_defaults_to_proxy_and_opts_into_bridge() { + assert_eq!(parse_browser_mode(None), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Bridge); + assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Bridge); + assert!(BrowserMode::Bridge.is_bridge()); + assert!(!BrowserMode::Proxy.is_bridge()); + } + struct MockTunnel; #[async_trait::async_trait] impl BrowserTunnel for MockTunnel { diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs index 39aa96a6d..3c63847b7 100644 --- a/src/browser_bridge.rs +++ b/src/browser_bridge.rs @@ -12,15 +12,6 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -/// Per-pod socket path; overridable via `OPENAB_BROWSER_SOCKET`. -fn socket_path() -> std::path::PathBuf { - if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { - return p.into(); - } - let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); - std::path::Path::new(&home).join(".openab").join("browser.sock") -} - /// Wrap one stdin MCP request line into a socket frame `{channel_id, request}`. Returns `None` /// for a blank/unparseable line (skip it) so a stray line can't break the relay. fn wrap_frame(channel: &str, line: &str) -> Option> { @@ -39,7 +30,8 @@ fn wrap_frame(channel: &str, line: &str) -> Option> { /// socket→stdout (verbatim MCP responses) until either side closes. pub async fn run() -> std::io::Result<()> { let channel = std::env::var("OPENAB_BROWSER_CHANNEL").unwrap_or_default(); - let sock = tokio::net::UnixStream::connect(socket_path()).await?; + let sock = + tokio::net::UnixStream::connect(openab_core::mcp_proxy::browser_socket_path()).await?; let (sock_rd, sock_wr) = sock.into_split(); pump( channel, diff --git a/src/main.rs b/src/main.rs index 3da07bbbc..bc894e96f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -404,9 +404,26 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let pool_inner = pool_inner.with_browser_tunnel(Some(Arc::new( + let browser_tunnel: Arc = Arc::new( browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), - ))); + ); + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_browser_tunnel(Some(browser_tunnel.clone())); + // Option C bridge mode: start the per-pod browser socket server once; the `openab + // browser-bridge` shims each agent spawns dial it. Proxy mode (default) skips this. + #[cfg(feature = "acp")] + if openab_core::mcp_proxy::browser_mode().is_bridge() { + let sock = openab_core::mcp_proxy::browser_socket_path(); + match openab_core::mcp_proxy::serve_browser_socket_forever( + sock.clone(), + Some(browser_tunnel.clone()), + ) + .await + { + Ok(()) => info!(?sock, "browser bridge socket serving (Option C)"), + Err(e) => warn!(?sock, error = %e, "failed to start browser bridge socket"), + } + } let pool = Arc::new(pool_inner); let ttl_secs = cfg.pool.session_ttl_hours * 3600; From 1d513766d7f158b7b956aa1506f2e256fde5cba2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 21 Jul 2026 00:53:11 +0800 Subject: [PATCH 37/46] fix(browser-bridge): resolve channel via process-ancestry, not env (Option C, b2 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP client scrubs the child env (cursor gives the bridge only HOME/PATH/USER or the pod env, never the per-session OPENAB_BROWSER_CHANNEL), so env inheritance can't carry the channel. resolve_channel() now walks up the PPID chain and reads OPENAB_BROWSER_CHANNEL from the ancestor agent's /proc//environ (openab injected it via the pool) — generic across all stdio-MCP vendors. Logs the resolved channel to stderr. + parse_ppid_from_stat / parse_channel_from_environ unit tests. Co-Authored-By: Claude Opus 4.8 --- src/browser_bridge.rs | 71 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs index 3c63847b7..84756fc02 100644 --- a/src/browser_bridge.rs +++ b/src/browser_bridge.rs @@ -26,10 +26,61 @@ fn wrap_frame(channel: &str, line: &str) -> Option> { Some(buf) } +/// Resolve this bridge's browser channel. The MCP client scrubs the child env (verified: cursor +/// launches us with only HOME/PATH/USER, or the pod env — never the per-session +/// OPENAB_BROWSER_CHANNEL openab injects into the AGENT), so we can't read it from our own env. +/// Instead walk UP the process tree and read OPENAB_BROWSER_CHANNEL from the first ancestor that +/// has it: the agent process openab spawned (channel injected via the pool) is always an ancestor +/// of this stdio MCP server, for EVERY vendor. Prefer our own env first (clients that DO pass it); +/// return "" if not found (core then reports "no browser attached"). +fn resolve_channel() -> String { + if let Ok(c) = std::env::var("OPENAB_BROWSER_CHANNEL") { + if !c.is_empty() { + return c; + } + } + let mut pid = std::process::id(); + for _ in 0..16 { + if let Ok(bytes) = std::fs::read(format!("/proc/{pid}/environ")) { + if let Some(c) = parse_channel_from_environ(&bytes) { + return c; + } + } + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + break; + }; + match parse_ppid_from_stat(&stat) { + Some(ppid) if ppid > 1 => pid = ppid, // step up (stop at init/tini = 1) + _ => break, + } + } + String::new() +} + +/// Extract OPENAB_BROWSER_CHANNEL from a null-separated `/proc//environ` blob. +fn parse_channel_from_environ(bytes: &[u8]) -> Option { + for kv in bytes.split(|&b| b == 0) { + if let Some(rest) = kv.strip_prefix(b"OPENAB_BROWSER_CHANNEL=") { + if !rest.is_empty() { + return Some(String::from_utf8_lossy(rest).into_owned()); + } + } + } + None +} + +/// Parse the parent PID from a `/proc//stat` line. Field 2 (`comm`) is parenthesized and may +/// contain spaces or `)`, so split after the LAST `)`: the remainder is "state ppid pgrp ...". +fn parse_ppid_from_stat(stat: &str) -> Option { + let after = stat.rsplit_once(')')?.1; + after.split_whitespace().nth(1)?.parse().ok() +} + /// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and /// socket→stdout (verbatim MCP responses) until either side closes. pub async fn run() -> std::io::Result<()> { - let channel = std::env::var("OPENAB_BROWSER_CHANNEL").unwrap_or_default(); + let channel = resolve_channel(); + eprintln!("[openab browser-bridge] resolved channel={channel:?}"); let sock = tokio::net::UnixStream::connect(openab_core::mcp_proxy::browser_socket_path()).await?; let (sock_rd, sock_wr) = sock.into_split(); @@ -93,7 +144,23 @@ where #[cfg(test)] mod tests { - use super::{pump, wrap_frame}; + use super::{parse_channel_from_environ, parse_ppid_from_stat, pump, wrap_frame}; + + #[test] + fn parse_ppid_handles_comm_with_spaces_and_parens() { + assert_eq!(parse_ppid_from_stat("834 (sh) S 25 834 25 0 -1 ..."), Some(25)); + assert_eq!(parse_ppid_from_stat("658 (cursor agent) R 25 658 ..."), Some(25)); + assert_eq!(parse_ppid_from_stat("5 (weird )proc) S 3 5 ..."), Some(3)); // ')' inside comm + assert_eq!(parse_ppid_from_stat("nonsense"), None); + } + + #[test] + fn parse_channel_from_environ_finds_the_var() { + let env = b"HOME=/home/agent\0OPENAB_BROWSER_CHANNEL=acp_xyz\0PATH=/bin\0"; + assert_eq!(parse_channel_from_environ(env).as_deref(), Some("acp_xyz")); + assert_eq!(parse_channel_from_environ(b"HOME=/x\0PATH=/y\0"), None); + assert_eq!(parse_channel_from_environ(b"OPENAB_BROWSER_CHANNEL=\0"), None); // empty ignored + } use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[test] From c83e399732e649ed7e06383388ba57ca862ae9b4 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 21 Jul 2026 01:06:06 +0800 Subject: [PATCH 38/46] feat(mcp-proxy): revert bridge config to pure {command,args} (Option C, b2 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ${OPENAB_BROWSER_CHANNEL} config env — cursor doesn't expand it (spawns from pod/clean env). The bridge now resolves its channel via process-ancestry (B1), so the config is a byte-identical static entry again: idempotent, never stale, no clobber. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 3c5d2c34d..bb329970a 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -326,6 +326,10 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< /// write suffers when several sessions of one agent share a single mcp.json. Merges without /// touching the user's other servers; idempotent (a no-op when already present + identical). pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { + // Pure static entry — byte-identical for every session (idempotent, no cross-session clobber). + // The channel is deliberately NOT carried here: the MCP client scrubs the server's env and its + // config-var expansion is vendor-specific, so the `openab browser-bridge` shim resolves its OWN + // channel by walking up to the agent process (Option C b2). This entry never goes stale. let entry = json!({ "command": "openab", "args": ["browser-bridge"] }); let cfg_paths = [ std::path::Path::new(workdir).join(".cursor").join("mcp.json"), @@ -701,6 +705,10 @@ mod tests { let e = &cfg["mcpServers"]["openab-browser"]; assert_eq!(e["command"], "openab"); assert_eq!(e["args"], serde_json::json!(["browser-bridge"])); + assert!( + e.get("env").is_none(), + "channel is resolved by the shim (b2), not carried in config" + ); assert!(e.get("url").is_none(), "bridge entry carries no url/port"); assert!(e.get("headers").is_none(), "bridge entry carries no bearer"); } From 3696d13d79b9bebdce77e07124c92d454f30b438 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 24 Jul 2026 15:55:58 +0800 Subject: [PATCH 39/46] fix(acp): add acp_mcp_servers to test-only AcpSession initializers The 4 AcpSession constructors in `mod acp_review_fixes` tests missed the acp_mcp_servers field added in T4, breaking `cargo test -p openab-gateway --features acp` (E0063). build/clippy don't compile this crate's test target under `acp`, so only CI caught it. Also syncs Cargo.lock to the already-committed openab 0.10.0 version. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- crates/openab-gateway/src/adapters/acp_server.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50855f6a4..2b5016965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,7 +2210,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openab" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "async-trait", diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 13c42456c..2be85c5ef 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -2528,6 +2528,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); // Cancel arrives before the handler's stream loop (reserved-then-immediate-cancel). @@ -2571,6 +2572,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); @@ -2604,7 +2606,7 @@ mod acp_review_fixes { let cancel = Arc::new(tokio::sync::Notify::new()); sessions.lock().await.insert( sid.clone(), - AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()) }, + AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()), acp_mcp_servers: Vec::new() }, ); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -2680,6 +2682,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); let params = json!({"sessionId": sid}); From 0ed644d8e04c8b7bde48be59a00f3f795dba64f2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 24 Jul 2026 23:24:20 +0800 Subject: [PATCH 40/46] docs(acp): split into reverse-MCP mechanism ADR + browser-control ADR, embed diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ADRs instead of one: - acp-server-websocket-reverse-mcp.md — the generic reverse-MCP-over-ACP mechanism (roles, call route, protocol gap, §6 multi-server generalization: compound-key routing, dynamic tools/list + list_changed, per-server Option B). Embeds the architecture + MCP-usage sequence diagrams (mermaid), using browser control as the example. Flipped Proposed -> Accepted (as-built in #1447). - acp-server-websocket-mcp-browser.md — the browser-specific design + the contract the browser extension implements (D1-D6, detailed id-paired runtime sequence, tasks, as-built). Defers the mechanism to the reverse-MCP ADR. Update base ADR + tunnel-contract cross-links; mark base §6 browser critical-path done. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-base.md | 7 +- docs/adr/acp-server-websocket-mcp-browser.md | 488 ++++++------------- docs/adr/acp-server-websocket-reverse-mcp.md | 228 +++++++++ docs/mcp-over-acp-tunnel-contract.md | 2 +- 4 files changed, 382 insertions(+), 343 deletions(-) create mode 100644 docs/adr/acp-server-websocket-reverse-mcp.md diff --git a/docs/adr/acp-server-websocket-base.md b/docs/adr/acp-server-websocket-base.md index c6f6a2de7..783cf4de5 100644 --- a/docs/adr/acp-server-websocket-base.md +++ b/docs/adr/acp-server-websocket-base.md @@ -217,9 +217,12 @@ and rejecting forged ids. ## 6. Roadmap (re-scoped; not the original proposal's numbered phases) North star: the agent's LLM autonomously operating the user's real browser (generalized -"computer use") — see [MCP-over-ACP browser control](./acp-server-websocket-mcp-browser.md). +"computer use") — see [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md). ### Critical path (next) — everything the browser goal requires +> **Done in #1447** — all four items below shipped (agent→client request direction, the +> MCP-over-ACP tunnel + core MCP proxy, and the generated v1 wire types). See the +> [Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md). - **agent→client REQUEST direction** — the base does only client→agent + agent→client *notifications*; browser/tool use needs the agent to send *requests* to the client and await a result. The WS is already bidirectional; the dispatch loop must add this path. @@ -329,4 +332,4 @@ Notes: - Original proposal: [acp-server-websocket.md](./acp-server-websocket.md) - Official method surface + coverage: [acp-official-methods.md](../acp-official-methods.md) -- MCP-over-ACP browser control: [acp-server-websocket-mcp-browser.md](./acp-server-websocket-mcp-browser.md) +- Reverse MCP-over-ACP over WebSocket: [acp-server-websocket-reverse-mcp.md](./acp-server-websocket-reverse-mcp.md) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index ea9f04694..a650296b8 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -1,187 +1,119 @@ -# ADR: Browser control via MCP-over-ACP (proposed) +# ADR: Browser control via MCP-over-ACP -- **Status:** Proposed (design only — not implemented). North-star capability the base - builds toward; see the base ADR §6 roadmap "Critical path". -- **Date:** 2026-07-18 +- **Status:** Accepted — OpenAB side **as-built in #1447** (compiles + unit-tested; live-validated + 2026-07-20). The browser extension implements the [tunnel contract](../mcp-over-acp-tunnel-contract.md). +- **Date:** 2026-07-18 (updated 2026-07-24) - **Author:** @brettchien -- **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), - [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), - [openab-agent MCP](./openab-agent-mcp.md) +- **Related:** **Mechanism — roles, call route, generalization, and the architecture + usage-sequence + diagrams: [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md).** + [Base (as-built)](./acp-server-websocket-base.md), [tunnel contract](../mcp-over-acp-tunnel-contract.md), + [browser MCP agent setup](../browser-mcp-agent-setup.md). --- -## 1. Context - -The base ships a 1:1 streaming chat ACP server at `GET /acp`; a browser side-panel -extension connects as an ACP client and drives an OpenAB agent. The next goal is for the -agent's **LLM to autonomously operate the user's browser** (click, read the DOM, navigate) -— i.e. browser "computer use", but targeting the user's real, logged-in Chrome session -rather than a sandbox VM. - -## 2. Decision - -Expose the browser as **MCP tools** and route them to the agent via **MCP-over-ACP**, -tunnelled over the **existing `/acp` WebSocket** the extension already holds. - -Why MCP (not a custom ACP `ExtRequest`): for the LLM to *autonomously* use browser -actions, they must appear in the agent's tool list (`tools/list`) so the model discovers -and calls them. A custom `ExtRequest` is a transport-level ACP extension the LLM never -sees as a tool — it only fits OpenAB-driven (non-LLM) operations. MCP is the standard way -agents receive tools, so browser actions must be MCP tools. - -### Roles -- **Extension = MCP server (role/logic).** It handles `tools/list` / `tools/call` and - executes DOM actions. An MV3 extension cannot open a *listening* socket, but MCP - server/client is about *who provides tools*, not who opens the connection — so the - extension serves MCP over the **outbound `/acp` WS it already opened**. This is the only - way a can't-listen extension can be a full MCP server. -- **OpenAB core = MCP proxy/aggregator.** OpenAB is a middlebox between two ACP - connections. It consumes the extension's tools from the upstream tunnel and re-exposes - them to the agent downstream (via `mcpServers`) so the LLM's `tools/list` sees them. -- **Agent = MCP client.** The agent (Claude / Codex / Cursor / Gemini …) is a subprocess - colocated in the OpenAB pod; it calls the tools over its in-pod ACP/MCP link. - -### Call route — the agent is in-pod; only the extension is remote -``` - REMOTE (user's browser) OPENAB POD (`openab run` — one process tree) - ┌──────────────────┐ ┌────────────────────────────────────────────────────┐ - │ browser extension│ │ ┌─────────┐ ┌───────────┐ ┌──────────────────┐ │ - │ = MCP SERVER │◀─/acp─▶│ │ gateway │──▶│ core │──▶│ agent CLI │ │ - │ browser tools │ WS │ │ /acp srv│ │ MCP proxy │ │ (subprocess) │ │ - └──────────────────┘ (only │ └─────────┘ └───────────┘ │ LLM (MCP client)│ │ - remote │ ▲ in-pod └──────────────────┘ │ - hop) │ └── stdio: ACP + MCP(mcpServers) ──┘ - └────────────────────────────────────────────────────┘ - - one tool call (LLM clicks a button); only ❸/❺ leave the pod: - ❶ LLM ─tools/call "browser.click"─▶ core (MCP proxy) [in-pod] - ❷ core ─▶ gateway ─❸ MCP-over-ACP──▶ extension [out of pod → remote] - ❹ extension runs it in the browser - ❺ result ──▶ gateway ─❻▶ core ─❼▶ LLM continues [remote → back in-pod] +## 1. Context & scope + +The [base](./acp-server-websocket-base.md) ships a 1:1 streaming chat ACP server at `GET /acp`; a +browser side-panel extension connects as an ACP client and drives an OpenAB agent. The goal here is +for the agent's **LLM to autonomously operate the user's real, logged-in Chrome** (click, read the +DOM, navigate) — browser "computer use" against the user's own session, not a sandbox VM. + +This ADR is the **browser-specific design** and the design the **browser extension** implements. The +underlying transport — how a can't-listen WS client serves MCP over its own `/acp` WS, the roles, +the call route, and the generalization to multiple servers — is the +[Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md); its **§4 architecture diagram** +and **§5 usage-sequence diagram** illustrate this exact browser flow. + +## 2. Browser toolset + +Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (snapshot), +`browser.screenshot`, `browser.navigate`, `browser.click(selector)`, `browser.type(selector, text)`. + +- **DOM-semantic, not a model-specific `computer` (pixel) tool** — `click(selector)` / `read_dom` + are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if + wanted, but are not the primary surface. +- **Screenshots are JPEG** (`captureVisibleTab {format:"jpeg", quality:70}`, ~300–500 KB); the ACP + frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) would exceed the cap. + +## 3. Design decisions (D1–D6) + +- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps + auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: + a dedicated `request_permission`-relay task is **dropped**, but the server→client request machinery + is still required for the upstream MCP tunnel. +- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` parameter + is **not** reliable: Cursor's CLI ignores ACP-passed MCP servers and only loads MCP from its **own + config** (`.cursor/mcp.json`) — see [zed#50924](https://github.com/zed-industries/zed/issues/50924). + So the proxy is registered **per-agent, in that agent's native MCP config** (Cursor → `.cursor/mcp.json`; + Kiro → `.kiro/settings/mcp.json`; others via their own file/format). The **content** (an HTTP MCP + entry: `url` + `headers`) is portable across vendors. +- **D3 — where MCP is tunnelled.** Downstream (agent ↔ core) is a **normal** in-process + Streamable-HTTP MCP server on `127.0.0.1:` (loopback + bearer, via `rmcp`); the agent connects + to it like any other MCP server. Only the **upstream** (core/gateway ↔ extension) is tunnelled — an + MV3 extension cannot listen — adopting the official + [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) framing (`mcp/connect` → + `connectionId`, then `mcp/message`), not a hand-rolled envelope. +- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on + and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless + of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"), + **plus** `notifications/tools/list_changed` on attach/detach. **Superseded as the default:** the + generic design ([reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md)) drops static-advertise + as the default in favour of dynamic `tools/list` forwarding + `list_changed`, keeping static-advertise + as an opt-in for the browser case. +- **D5 — per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per `acp:` + session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so + correlation is implicit. Server lifetime is tied to the `AcpConnection` via a `CancellationToken` + `DropGuard`, so it stops on any evict path. +- **D6 — tunnel trait in core, impl in root.** `openab-core` defines `mcp_proxy::BrowserTunnel` + (generically `AcpMcpTunnel` under [reverse-MCP ADR §6.1](./acp-server-websocket-reverse-mcp.md)); the + **root** binary implements it (`src/browser_tunnel.rs`) by looking up the gateway's + `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and + `openab-gateway` sibling-independent (no cross-crate dep), mirroring the `ChatAdapter` root-glue pattern. + +## 4. Runtime sequence (detailed) — one `browser.click` round-trip + +The high-level phase diagram is in [reverse-MCP ADR §5](./acp-server-websocket-reverse-mcp.md); this is +the message-level detail, including the **two id spaces**. + ``` +Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) + G = gateway (/acp WS srv) E = extension (MCP server, browser) -### One WebSocket, multiplexed -The single `/acp` WS carries BOTH the ACP chat session (initialize / session.prompt / -session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), -distinguished by ACP method namespace. No second connection. - -> **Refinement (see §7 "Design decisions"):** this multiplexing applies to the **upstream** -> hop (extension ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The -> **downstream** hop (core ↔ agent) is *not* tunnelled over ACP — core hosts a normal -> in-process HTTP MCP server the agent connects to. Only the extension, which cannot open a -> listening socket, needs MCP tunnelled over its `/acp` WS. - -## 3. Protocol gap to close first - -The base does only client→agent (prompt) and agent→client **notifications** (streaming -text). Browser control needs the **agent→client REQUEST** direction (request/response: -the agent asks the client to do X and awaits a result). The WS is already bidirectional; -`acp_server`'s dispatch loop must add the agent-initiated-request path. This is also the -point to move the wire types from hand-rolled to **generated** (see §5). - -## 4. Alternatives considered - -- **Custom `ExtRequest` per browser action** — rejected: not surfaced to the LLM as a - tool, so the model can't autonomously call it. Fits OpenAB-driven ops only. -- **Extension hosts a standalone MCP server (HTTP/SSE)** — rejected: MV3 extensions - cannot open a listening socket. -- **Anthropic-style `computer` tool (screenshot + pixel coords)** — subsumed: you can - expose `screenshot` + `click(x,y)` as MCP tools if desired, but DOM-semantic tools - (`click(selector)`, `read_dom`) are cheaper/more reliable and model-agnostic. - -## 5. Typing / dependencies - -- Bidirectional tool-call / client-method messages are exactly where hand-rolling breaks; - adopt **generated types** for the expanded surface. Use **v1** schema (stable; `v2` is - experimental and currently wire-identical). Prefer offline codegen (e.g. `typify`) to - emit plain-serde types — this avoids the `schemars`-heavy dependency tree the official - `agent-client-protocol-schema` crate pulls in for `JsonSchema` derives OpenAB doesn't - use at runtime. -- The MCP protocol machinery itself (handshake, tool lifecycle, tunnel framing) is NOT - just types — it needs an MCP implementation (e.g. `rmcp`, already used by - `openab-agent`), plus the ACP-tunnel transport glue. - -## 6. Relationship to Computer Use - -Same category as browser "computer use" (LLM autonomously drives a browser via a -perceive→act tool loop), but generalized: (a) targets the **user's real Chrome** (live, -logged-in), not a sandbox; (b) action surface is **extension-defined MCP tools** -(DOM-semantic or screenshot), not a model-specific tool; (c) **model-agnostic** — any -MCP-capable agent can use it. - -## 7. Implementation blueprint (task breakdown) - -North-star = the agent's LLM autonomously operating the user's browser via MCP tools -tunnelled over `/acp`. The base (PR that revives #1260) ships the 1:1 chat surface and the -**generated v1 wire types** (`acp_schema`, already committed) — one of the four critical- -path items is therefore done. What remains splits cleanly into an **OpenAB (server) side** -and an **extension (client) side**, meeting at a single **MCP-over-ACP wire contract** (T4) -so the two can proceed largely in parallel once that contract is fixed. - -### TL;DR — how one browser action flows +Transports --ACP--> downstream ACP over stdio (chat / permission) + --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) + ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) -``` - [ LLM ] ────▶ [ OpenAB (core) ] ────▶ [ browser extension ] - wants to act middle-man / relay operates the real tab - ────────────────────────────────────────────────────────────────────── - inside the server pod in the user's browser (remote) - - Request 1. LLM decides "click the Submit button" - 2. OpenAB relays the action to the extension in the user's browser - 3. the extension actually clicks it in the active tab - Result 4. the extension reports "clicked; page went to /thanks" - 5. OpenAB hands the result back to the LLM - 6. the LLM continues → the user sees its narration in the side panel - - The LLM thinks it is calling an ordinary set of tools; in reality OpenAB is a - middle-man relaying every action to the real remote browser (and relaying the - tool list the other way). Only the OpenAB↔browser leg leaves the server; the - LLM↔OpenAB legs stay in-pod. The detailed message-level sequence is below. +Precondition: session open, extension WS attached, tools/list already discovered +-------------------------------------------------------------------------------- + 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 + 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 + .............................................................................. + 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 + 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 + params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 + 5 G ==WS===> E server->client request = MCP-over-ACP outer id=acp#55 <-off-pod + 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks + 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 + 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 + .............................................................................. +10 A LLM consumes the tool result, keeps reasoning +11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) +12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod +-------------------------------------------------------------------------------- +Two id spaces (never mixed) + - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the RFD, + mcp/message FLATTENS the inner method/params and does NOT carry an inner MCP id, so + mcp#7 never travels on the tunnel. + - acp#55 = outer ACP-envelope id correlating the whole upstream round-trip (steps 4<->8); the + response result IS the inner MCP result payload. The core proxy maps mcp#7 <-> acp#55. + - acp#1 = downstream ACP permission id; unrelated to the two above + +Only steps 5/7/12 leave the pod (all on the /acp WS). If the extension is not attached at step 5, +core returns an MCP error "browser not connected" (D4 static-advertise: fails gracefully, no crash). ``` -### Design decisions (resolved 2026-07-19) - -Four decisions were worked through and locked; they refine §2 and the tasks below. - -- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core - keeps auto-replying `session/request_permission` with OK (existing - `connection.rs` behaviour); fine-grained control is deferred. Consequence: a dedicated - `request_permission`-relay task (was T3) is **dropped**, but T1's server→client request - machinery is still required — the **upstream MCP tunnel** needs it. - -- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` - parameter is **not** reliable for this: Cursor's CLI ignores ACP-passed MCP servers and - only loads MCP from its **own config** (`.cursor/mcp.json`) — see - [zed-industries/zed#50924](https://github.com/zed-industries/zed/issues/50924). So the - proxy is registered **per-agent, in that agent's native MCP config** (Cursor → - `.cursor/mcp.json`; others via their own file/format — there is no universal location: - VS Code uses the `servers` key, Codex uses TOML). The **content** (an HTTP MCP entry: - `url` + `headers`) is portable across vendors, so "as long as it loads, we're fine". - -- **D3 — where MCP is tunnelled.** **Downstream (agent ↔ core) is a *normal* MCP server, - not an on-ACP-stream tunnel.** The ACP maintainer prototyped on-stream MCP-over-ACP and - backed off — agents already connect to MCP servers well, and a special on-stream MCP type - is invasive - ([discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). So core - hosts a **Streamable-HTTP MCP server in-process** on `127.0.0.1:` (loopback + bearer, - via `rmcp`); the agent connects to it like any other MCP server. The **upstream** - (core/gateway ↔ extension) is the one legitimate tunnel (an MV3 extension cannot listen), - and it adopts the **official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp)** - framing (`mcp/connect` → `connectionId`, then `mcp/message`), *not* a hand-rolled envelope. - The RFD's own `"type":"acp"` downstream-injection path is **not** used (Cursor doesn't - support it; see D2). - -- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is - **always-on and decoupled** from the extension WS; the extension connecting/disconnecting - only changes *backend availability*. To let browser tools appear on a session whose WS - attaches late (reconnect, or attach-to-running-session), core does **both**: (a) **static- - advertise** the fixed browser toolset regardless of WS state — a `tools/call` while no - extension is attached returns an MCP error ("browser not connected"), which decouples WS - timing from session start with no client dependency; **and** (b) emit - `notifications/tools/list_changed` when the extension attaches/detaches, so agents that - re-query pick up extension-defined extras and fresh schema. - -### Execution flow (as designed) +## 5. Execution flow (bootstrap → discovery → runtime) ``` Legend = ACP (JSON-RPC over stdio) - HTTP MCP (loopback) <=> /acp WS (only hop off-pod) @@ -210,169 +142,37 @@ Discovery (tools/list) D3 extension returns [click, read_dom, navigate, type, screenshot] D4 core returns the list --http--> agent → the LLM now sees browser tools -Runtime (LLM clicks a button) - 1 LLM decides browser.click{selector} - 2 agent ==session/request_permission==> core → core auto-approves (D1) ==OK==> agent - 3 agent --http tools/call browser.click--> core proxy [in-pod] - 4 core --mcp/message: tools/call--> gateway - 5 gateway ==server→client request==> <=> extension (leaves pod) - 6 extension runs chrome.scripting click in the active tab - 7 extension ==result==> <=> gateway (back in pod) - 8 gateway → core (match pending id) --http result--> agent → LLM continues - -Only steps 5/7 leave the pod. Outer tunnel ids are paired by the gateway pending-map; the -inner MCP ids are the MCP layer's own bookkeeping and are never inspected by the gateway. +Runtime → see §4 for the detailed id-paired round-trip. ``` -### Runtime sequence (detailed) — one `browser.click` round-trip - -``` -Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) - G = gateway (/acp WS srv) E = extension (MCP server, browser) - -Transports --ACP--> downstream ACP over stdio (chat / permission) - --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) - ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) - -Precondition: session open, extension WS attached, tools/list already discovered --------------------------------------------------------------------------------- - 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 - 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 - .............................................................................. - 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 - 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 - params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 - 5 G ==WS===> E server->client request (T1) = MCP-over-ACP outer id=acp#55 <-off-pod - 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks - 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod - 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 - 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 - .............................................................................. -10 A LLM consumes the tool result, keeps reasoning -11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) -12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod --------------------------------------------------------------------------------- -Two id spaces (never mixed) - - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the - MCP-over-ACP RFD, mcp/message FLATTENS the inner method/params and does NOT - carry an inner MCP id, so mcp#7 never travels on the tunnel. - - acp#55 = outer ACP-envelope id that correlates the whole upstream tunnel round-trip - (steps 4<->8); the response result IS the inner MCP result payload. The core - proxy maps its downstream mcp#7 <-> the upstream acp#55. - - acp#1 = downstream ACP permission id; unrelated to the two above - -Only steps 5/7/12 leave the pod (all on the /acp WS). Permission (1-2) and tool transport -(3, 9) stay in-pod on loopback. If the extension is not attached at step 5, core returns an -MCP error "browser not connected" (D4 static-advertise: calls fail gracefully, no crash). -``` - -### T0 spike checklist (what the live PoC must confirm) - -1. Cursor loads an **HTTP** MCP server registered in `.cursor/mcp.json` (auto, or needs a - one-time `cursor-agent mcp enable`). -2. Cursor honours `notifications/tools/list_changed` and re-fetches mid-session (validates - D4(b); if not, D4(a) static-advertise carries it). -3. Cursor handles a `tools/call` error ("browser not connected") gracefully. - -### Findings that reshape the work -- The **agent→client REQUEST direction already exists on the downstream hop**: - `openab-core/src/acp/connection.rs` receives `session/request_permission` from the agent - and currently **auto-replies** it (~L252). So T1 is not green-field — it is *relaying* - those downstream requests up to the `/acp` client (and the response back) instead of - auto-answering them. -- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784), - but that path is **not** how the agent gets the browser tools (see D2 — Cursor ignores - ACP-passed MCP servers). Giving the agent browser tools = core **hosts a proxy HTTP MCP - server** and registers it in the agent's **native** MCP config (T5); the proxy tunnels - `tools/*` to the extension over the upstream MCP-over-ACP link. - -### Ownership -- **OpenAB side** (`feat/acp-mcp-browser`): T1, T2, T4 (contract + core routing), T5. -- **Extension side** (katashiro): T6; plus the client half of T4 (serve MCP over the - tunnel). (Permission is auto-approved by core per D1, so no consent UX is needed yet.) -- **Both**: T7. - -### Tasks - -**T0 — Spike (do first; de-risks everything).** PoC per the **T0 spike checklist** above: -register a mock **HTTP** MCP server in Cursor's `.cursor/mcp.json` and confirm the LLM -discovers (`tools/list`) and calls (`tools/call`) a tool, honours `tools/list_changed`, and -handles a call error gracefully. If this doesn't hold, the browser goal needs a different -path. (The agent→client request direction it depends on already exists downstream — see -Findings.) - -**T1 — agent→client REQUEST direction (relay).** -- 1.1 Decide to relay downstream requests (`request_permission`, later MCP) to the `/acp` - client instead of auto-replying; enumerate the relayed methods. -- 1.2 Gateway outbound request path: `acp_server` sends an agent-initiated REQUEST - (method + id) to the client and keeps a pending-response map (`id → oneshot`). -- 1.3 Read loop distinguishes an inbound **client response** (`id` + `result`/`error`, no - `method`) from a client request, and routes responses to the pending map. -- 1.4 core↔gateway bridge: relay the downstream request up + the client's response back - down to the agent. -- 1.5 Round-trip tests (agent request → client → response → agent). - -**T2 — migrate `acp_server` to generated typed wire (bidirectional surface).** -- 2.1 Construct response payloads from `acp_schema` types (the deferred construction - migration). 2.2 Type the new bidirectional messages (`request_permission`, MCP tunnel). - 2.3 Round-trip validate against real traffic (ACP trace mode). - -**T3 — `session/request_permission`.** **Dropped** per D1 (auto-approve; core keeps -auto-replying). Fine-grained permission control is a later, separate effort. - -**T4 — MCP-over-ACP tunnel framing (upstream only).** Adopt the official -[MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) rather than a -hand-rolled envelope (D3). -- 4.1 `mcp/connect` (→ `connectionId`) + `mcp/message` (carries the inner MCP JSON-RPC; - outer ACP id ↔ pending-map, inner MCP id opaque) + `mcp/disconnect`. 4.2 Gateway routes - these between the `/acp` client (extension) and core. 4.3 Contract doc — done: - [MCP-over-ACP tunnel — extension implementation contract](../mcp-over-acp-tunnel-contract.md). - 4.4 Mock-MCP-over-tunnel tests. - -**T5 — OpenAB core = MCP proxy/aggregator.** -- 5.1 A core-side **Streamable-HTTP MCP server hosted in-process** on `127.0.0.1:` - (loopback + bearer) that the agent connects to (D3). -- 5.2 **Per-agent adapter** registers that server in the agent's native MCP config (Cursor → - `.cursor/mcp.json`) before boot, *not* via ACP `session/new mcpServers` (D2). -- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel (T4). -- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent); static- - advertise the browser toolset + emit `tools/list_changed` on attach/detach (D4). -- 5.5 `rmcp` wiring (already used by `openab-agent`) + tests. - -**T6 — extension = MCP server + browser tools** (katashiro). -- 6.1 MCP server role over the outbound `/acp` WS (`tools/list` / `tools/call`). -- 6.2 DOM-semantic tools: `click(selector)` / `read_dom`(snapshot) / `navigate` / - `screenshot` / `type(selector, text)`. 6.3 Execute in the active tab - (`chrome.scripting` / content script + permissions). 6.4 Consent UX for - `request_permission`. 6.5 Tests. - -**T7 — integration + e2e + deploy.** -- 7.1 Full loop: `tools/list` → LLM calls `browser.click` → extension executes → result → - LLM continues. 7.2 A browser-loop e2e (extend `scripts/acp-ws-smoke.py`). - 7.3 Rebuild + redeploy the deployed Cursor agent. 7.4 Finalize this ADR. - -### Suggested order -T0 spike → T1 (server→client request direction) → T2 → **T4 (adopt the RFD framing)** → T5 -→ then T6 in parallel against the contract → T7. The heavy items are T1 (the direction), -T4/T5 (tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is -parallel and non-blocking. - -### As-built (2026-07-20) — OpenAB side wired end-to-end - -The OpenAB (server) side is implemented on `feat/acp-mcp-browser` (compiles + unit-tested; -live path pending the extension T6 + deploy T7). Two decisions beyond D1–D4 settled during -implementation: - -- **D5 = per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per - `acp:` session (in `openab-core/src/acp/pool.rs`, at agent spawn), constructing the - `ProxyHandler` with that session's `channel_id` so correlation is implicit — it binds to the - existing `session_key`/`channel_id` map, no in-band id. Server lifetime is tied to the - `AcpConnection` via a `CancellationToken` `DropGuard`, so it stops on any evict path. -- **D6 = tunnel trait in core, impl in root.** `openab-core` defines - `mcp_proxy::BrowserTunnel`; the **root** binary implements it (`src/browser_tunnel.rs`) - by looking up the gateway's `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. - This keeps `openab-core` and `openab-gateway` **sibling-independent** (no cross-crate dep), - mirroring the existing `ChatAdapter`/`GatewayResponse` root-glue pattern. +## 6. Findings & ownership + +- The **agent→client REQUEST direction already existed downstream**: `openab-core`'s ACP connection + receives `session/request_permission` from the agent and auto-replies it. So the server→client + request work is *relaying* those upstream to the `/acp` client, not green-field. +- `session/new` / `session/resume` send `mcpServers: []`, but that path is **not** how the agent gets + the tools (D2 — Cursor ignores ACP-passed MCP servers). Tools reach the agent via core's proxy HTTP + MCP server registered in the agent's **native** config. +- **Ownership** — OpenAB side (`feat/acp-mcp-browser`): server→client request direction, generated + typed wire, tunnel framing + core proxy. Extension side (katashiro): MCP server role over the + outbound `/acp` WS + the DOM tools + executing in the active tab. Both: integration/e2e. + +## 7. Tasks (as executed) + +- **T0 spike** — confirm a CLI loads an HTTP MCP server from its native config, honours + `tools/list_changed`, and handles a `tools/call` error gracefully. +- **T1** agent→client REQUEST direction (relay; gateway outbound request + pending-response map; + read loop distinguishes client response vs request). +- **T2** migrate `acp_server` to generated typed wire (bidirectional surface). +- **T3** `session/request_permission` — **dropped** (D1 auto-approve). +- **T4** MCP-over-ACP tunnel framing (upstream only) — adopt the RFD (`mcp/connect` + `mcp/message` + + `mcp/disconnect`); contract doc: [tunnel contract](../mcp-over-acp-tunnel-contract.md). +- **T5** OpenAB core = MCP proxy/aggregator (in-process HTTP MCP server; per-agent config injection; + proxy as MCP client to the extension over the tunnel; tool-call routing; `rmcp` wiring). +- **T6** extension (katashiro) = MCP server + the five DOM tools, executing via `chrome.scripting`. +- **T7** integration + e2e (`scripts/acp-ws-smoke.py`) + deploy. + +## 8. As-built (2026-07-20, OpenAB side wired end-to-end) Realised call path (all in one `openab run` process): @@ -383,8 +183,16 @@ agent tools/call ─http▶ core per-session ProxyHandler (mcp_proxy.rs) ═mcp/message═▶ extension (only this hop leaves the pod) ``` -Config injection is per-agent (`.cursor/mcp.json` merged at the session workdir, loopback + -bearer). Static-advertise + not-connected fallback (D4) hold when no browser is attached. -**Remaining:** T5.4 `tools/list_changed` (enhancement; static-advertise already covers the -disconnected case), T6 extension (katashiro — see the -[tunnel contract](../mcp-over-acp-tunnel-contract.md)), and T7 live e2e + deploy. +Config injection is per-agent (`.cursor/mcp.json` / `.kiro/settings/mcp.json` merged at the session +workdir, loopback + bearer). The full loop (read_dom / screenshot / navigate / click / type + status +pill + reconnect on `session/resume`) was live-validated on a real deployment on 2026-07-20. A second +downstream delivery mode — `bridge` (stdio relay, `OPENAB_BROWSER_MODE`) — is also shipped; see the +[reverse-MCP ADR §6.3](./acp-server-websocket-reverse-mcp.md). + +## 9. References + +- **Mechanism:** [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md) + (roles, call route, architecture + sequence diagrams, multi-server generalization) +- [Base ADR](./acp-server-websocket-base.md) · [tunnel contract](../mcp-over-acp-tunnel-contract.md) · + [browser MCP agent setup](../browser-mcp-agent-setup.md) +- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md new file mode 100644 index 000000000..e85cd5211 --- /dev/null +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -0,0 +1,228 @@ +# ADR: Reverse MCP-over-ACP over WebSocket + +- **Status:** Accepted — the mechanism is **as-built in #1447**; the generic multi-server + generalization (§6) is accepted and implementing in the same PR. +- **Date:** 2026-07-18 (updated 2026-07-24) +- **Author:** @brettchien +- **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), + [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), + [openab-agent MCP](./openab-agent-mcp.md). + **Browser-specific design + the contract the extension implements:** + [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md). + +--- + +## 1. Context + +This ADR records **reverse MCP-over-ACP**: a mechanism that lets an ACP **WebSocket client** — +one that cannot open a listening socket — nevertheless act as an **MCP server**, serving its +tools to a colocated agent over the outbound `/acp` WS it already holds. OpenAB core is the MCP +proxy/aggregator in the middle; the agent is a normal in-pod MCP client. + +The first, driving consumer is **browser control**: a browser side-panel extension serves DOM +tools so the agent's LLM can autonomously operate the user's real, logged-in Chrome (see the +[browser ADR](./acp-server-websocket-mcp-browser.md) for that concrete design and the extension +contract). This ADR describes the general mechanism and its generalization to **multiple, +arbitrary** client-side MCP servers (§6), using browser control as the running example. + +## 2. Decision + +Expose a client-side capability as **MCP tools** and route them to the agent via **MCP-over-ACP**, +tunnelled over the **existing `/acp` WebSocket** the client already holds. + +Why MCP (not a custom ACP `ExtRequest`): for the LLM to *autonomously* use a capability, its +actions must appear in the agent's tool list (`tools/list`) so the model discovers and calls them. +A custom `ExtRequest` is a transport-level ACP extension the LLM never sees as a tool — it only +fits OpenAB-driven (non-LLM) operations. MCP is the standard way agents receive tools. + +### Roles +- **ACP WS client = MCP server (role/logic).** It handles `tools/list` / `tools/call` and executes + the actions. A client that cannot open a *listening* socket (e.g. an MV3 browser extension) can + still be an MCP server — MCP server/client is about *who provides tools*, not who opens the + connection — so it serves MCP over the **outbound `/acp` WS it already opened**. This is the only + way a can't-listen client can be a full MCP server. +- **OpenAB core = MCP proxy/aggregator.** A middlebox between two connections: it consumes the + client's tools from the upstream tunnel and re-exposes them to the agent downstream so the LLM's + `tools/list` sees them. +- **Agent = MCP client.** The agent (Claude / Codex / Cursor / Kiro …) is a subprocess colocated in + the OpenAB pod; it calls the tools over its in-pod MCP link. + +### One WebSocket, multiplexed +The single `/acp` WS carries BOTH the ACP chat session (initialize / session.prompt / +session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), distinguished by +ACP method namespace. No second connection. This multiplexing applies to the **upstream** hop +(client ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The **downstream** hop +(core ↔ agent) is *not* tunnelled over ACP — core hosts a normal in-process MCP server the agent +connects to; only the client, which cannot listen, needs MCP tunnelled over its `/acp` WS. + +## 3. Protocol gap to close first + +The base does only client→agent (prompt) and agent→client **notifications** (streaming text). +Reverse MCP needs the **agent→client REQUEST** direction (request/response: the agent asks the +client to do X and awaits a result). The WS is already bidirectional; `acp_server`'s dispatch loop +adds the agent-initiated-request path. This is also where the wire types move from hand-rolled to +**generated** (see §8). + +## 4. Architecture (browser control as the example) + +```mermaid +flowchart LR + EXT["Side-panel MV3 extension = MCP SERVER
(cannot open a listening socket → serves MCP
over the outbound /acp WS it already holds)
tools: read_dom · screenshot · navigate · click · type"] + subgraph POD["OPENAB POD — 'openab run', one process tree"] + direction LR + GW["openab-gateway
/acp WS server
AcpTunnelRegistry"] + CORE["openab-core
MCP proxy /
aggregator"] + AGENT["agent CLI
Cursor · Kiro · Claude · Codex
LLM = MCP CLIENT"] + GW <--> CORE + CORE ==>|"proxy mode (default)
per-session loopback HTTP MCP
{url,headers} → .cursor / .kiro mcp.json
bearer-gated · 0600 · stripped on evict"| AGENT + CORE -.->|"bridge mode (Option C)
per-pod unix socket + stdio relay
'openab browser-bridge' · static {command,args}
channel via process-ancestry (multi-window)"| AGENT + end + EXT <==>|"UPSTREAM — only remote hop
MCP-over-ACP · mcp/message framing
multiplexed with ACP chat on ONE /acp WSS
8 MiB frame cap · JPEG screenshots"| GW + classDef remote fill:#fde68a,stroke:#b45309,color:#111; + classDef pod fill:#bfdbfe,stroke:#1e40af,color:#111; + class EXT remote; + class GW,CORE,AGENT pod; +``` + +Only the client (extension) is remote; core, gateway and agent are one in-pod `openab run` process +tree. The downstream hop has two delivery modes (§ browser ADR / §6.3): `proxy` (HTTP MCP, default) +and `bridge` (stdio relay). + +## 5. MCP usage sequence (browser.click as the example) + +```mermaid +sequenceDiagram + autonumber + participant Tab as Chrome tab
(user's real, logged-in) + participant Ext as browser ext.
MCP SERVER + participant GW as openab-gateway
/acp WS + participant Core as openab-core
MCP proxy + participant LLM as agent LLM
MCP client + + Note over Ext,LLM: PHASE 1 — connect & tool discovery + Ext->>GW: WS GET /acp — initialize
mcpServers = [ type:acp, "openab-browser" ] + GW-->>Ext: initialize result (agentCapabilities) + Ext->>GW: session/new (or session/resume on reconnect) + GW->>GW: register per-session TunnelHandle
(AcpTunnelRegistry) + GW->>Core: spawn agent + start per-session MCP proxy + Core->>Core: write "openab-browser" into agent's mcp.json
proxy: {url,headers} · bridge: static {command,args} + LLM->>Core: MCP initialize + tools/list + Core->>GW: tools/list (MCP-over-ACP: mcp/message frame) + GW->>Ext: mcp/message → tools/list + Ext-->>GW: 5 tools: read_dom · screenshot · navigate · click · type + GW-->>Core: tools result + Core-->>LLM: tools/list — browser tools now in the model's tool list + + Note over Tab,LLM: PHASE 2 — one autonomous action (e.g. click) + LLM->>Core: tools/call browser.click(selector) + Core->>GW: tools/call (mcp/message over the SAME /acp WS) + GW->>Ext: mcp/message → tools/call + Ext->>Tab: chrome.scripting / tabs API
click · type · read_dom · captureVisibleTab · navigate + Tab-->>Ext: DOM mutated / navigated / pixels + Ext-->>GW: tool result
(screenshot = JPEG q70, frame <= 8 MiB) + GW-->>Core: result + Core-->>LLM: tool result + LLM->>GW: session/update agent_message_chunk (narration) + GW->>Ext: streamed to the side panel + + Note over GW,Ext: only the gateway-to-extension hop leaves the pod. LLM, core and gateway stay in-pod. +``` + +The exact two-id-space bookkeeping (outer ACP-envelope id ↔ inner MCP id, flattened per the RFD) is +detailed in the [browser ADR](./acp-server-websocket-mcp-browser.md) §4. + +## 6. Generalization — multiple client-side MCP servers + +The browser path wires **one** MCP server. This section is the accepted direction (implementing in +#1447) to make reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** +`type:acp` MCP servers on `initialize`, and the agent's LLM discovers and calls each server's real +tools. The browser extension becomes *one instance* of the mechanism, not a special case. + +Three pieces already generalize and are reused as-is: +- `parse_acp_mcp_servers` already parses **N** `type:acp` entries with arbitrary `{id, name}`. +- `establish_and_register_tunnel(…, srv.id, …)` already threads the declared `srv.id` into + `mcp/connect` — the wire already carries a per-server discriminator. +- `ProxyHandler::forward_tool_call` forwards **any** tool name+args down the tunnel — no + browser-specific validation. + +### 6.1 Address every hop by `(channel_id, serverId)` +- `AcpTunnelRegistry` becomes keyed by `(channel_id, serverId)` instead of `channel_id` alone — the + "one tunnel per session" collapse was a fan-out fix; the correct fix is a **compound key**. +- Rename the core trait `BrowserTunnel` → **`AcpMcpTunnel`**; `call(channel_id, server_id, method, params)`. +- Evict all `(channel_id, *)` entries on session teardown. + +### 6.2 Dynamic tool discovery (supersedes static-advertise as the default) +- Each per-server proxy's `tools/list` forwards the client server's **real** tool list over its tunnel. +- **Unattached / reconnecting:** return an empty list for that server — never fabricate tools. +- **Attach → refresh:** on tunnel attach (or client re-declare on `session/resume`), push + `notifications/tools/list_changed` downstream so the agent re-lists. This preserves the + "usable before/without attach" UX that the browser's static-advertise (D4) gave, honestly. +- Keep a per-server **cache** of the last good `tools/list` to survive brief reconnects; **debounce** + `list_changed` against reconnect storms. +- The browser's static-advertise stays only as an **opt-in** fallback for the browser case; it is no + longer the default. + +### 6.3 Per-server downstream exposure (Option B — decided) +Each declared client server is surfaced to the agent as its **own** MCP server entry (`openab-`), +not merged into one namespaced blob: +- **proxy mode (HTTP):** one loopback MCP server per `(session, server)`, its own port + bearer; + openab writes **N entries** into the agent's native MCP config, one per server. +- **bridge mode (stdio):** the static entry gains a selector — + `{"command":"openab","args":["mcp-bridge","--server",""]}` — one relay per server (rename the + `browser-bridge` subcommand to `mcp-bridge`, keeping `browser-bridge` as a compat alias). + +Rejected — **Option A** (one aggregating proxy, `__` namespacing): the prefix leaks into +the tool names the model sees and needs reversible de-namespacing on every call. Option B is cleaner +for the LLM and maps to MCP's native "one server = one connection" model. (A stays a possible future mode.) + +### 6.4 Backward compatibility +The browser extension is unchanged: it declares `{type:acp, id, name:"openab-browser"}` and serves its +five DOM tools via its own `tools/list` — now discovered dynamically instead of static-advertised. Both +downstream modes are retained; single-server (browser-only) sessions behave identically. + +### 6.5 Generic implementation plan (folded into #1447) +- **P1** compound-key registry + `serverId` on the tunnel trait (no behaviour change; browser stays single). +- **P2** dynamic `tools/list` forwarding + per-server cache + `list_changed` attach/detach lifecycle. +- **P3** per-server downstream exposure (Option B) in both proxy + bridge modes; loop config-writing. +- **P4** generalize naming (`AcpMcpTunnel`, `openab-`, `openab mcp-bridge`) + error strings. +- **P5** e2e: a second, non-browser `type:acp` MCP server declared alongside the browser — both + discovered and callable in one session. + +## 7. Alternatives considered + +- **Custom `ExtRequest` per action** — rejected: not surfaced to the LLM as a tool, so the model + can't call it autonomously. Fits OpenAB-driven ops only. +- **Client hosts a standalone MCP server (HTTP/SSE)** — rejected for can't-listen clients: an MV3 + extension cannot open a listening socket. +- **On-stream MCP-over-ACP for the downstream hop** — rejected: agents already connect to normal MCP + servers well; a special on-stream MCP type is invasive + ([ACP discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). Only the + can't-listen *client* leg is tunnelled; downstream stays a normal in-process MCP server. +- **Static-advertise as the default** — superseded by §6.2 (dynamic + `list_changed`); kept as an + opt-in for browser only. + +## 8. Typing / dependencies + +- Bidirectional tool-call / client-method messages are where hand-rolling breaks; the expanded + surface uses **generated** serde-only **v1** wire types (offline `typify` codegen, avoiding the + `schemars`-heavy `agent-client-protocol-schema` crate). Landed in the base. +- The MCP machinery (handshake, tool lifecycle, tunnel framing) needs an MCP implementation + (`rmcp`, already used by `openab-agent`) plus the ACP-tunnel transport glue. + +## 9. Relationship to Computer Use + +Same category as "computer use" (LLM autonomously drives an app via a perceive→act tool loop), but +generalized: (a) targets the **user's real** app/session (e.g. logged-in Chrome), not a sandbox; (b) +the action surface is **client-defined MCP tools** (DOM-semantic or screenshot), not a model-specific +tool; (c) **model-agnostic** — any MCP-capable agent can use it. + +## 10. References + +- [Base ADR](./acp-server-websocket-base.md) · [Original proposal](./acp-server-websocket.md) · + [openab-agent MCP](./openab-agent-mcp.md) +- **Browser-specific design + extension contract:** + [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md) +- [MCP-over-ACP tunnel contract](../mcp-over-acp-tunnel-contract.md) · + [Browser MCP agent setup](../browser-mcp-agent-setup.md) +- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) · MCP + `notifications/tools/list_changed` diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index 157bff0f1..af82b4f43 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -2,7 +2,7 @@ This is the wire contract the **browser extension** (the ACP client / MCP server end) implements so the OpenAB gateway can tunnel MCP to it over the existing `/acp` WebSocket, per -[ADR: Browser control via MCP-over-ACP](./adr/acp-server-websocket-mcp-browser.md). It adopts +[ADR: Reverse MCP-over-ACP over WebSocket](./adr/acp-server-websocket-reverse-mcp.md). It adopts the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp). Scope: only the **gateway ↔ extension** hop (the sole hop that leaves the pod). How OpenAB From 144c232da766845d0a20833e4c8e96184c5dc9f2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 25 Jul 2026 00:02:19 +0800 Subject: [PATCH 41/46] docs(adr): correct D4 list_changed overclaim in browser ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_changed is designed but not yet implemented (0 hits in gateway/core crates); it was described as shipped alongside static-advertise. Reword to mark it as P2b-tracked (reverse-MCP §6.2), not as-built. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index a650296b8..efe3eceba 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -55,8 +55,10 @@ Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (sn `connectionId`, then `mcp/message`), not a hand-rolled envelope. - **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless - of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"), - **plus** `notifications/tools/list_changed` on attach/detach. **Superseded as the default:** the + of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"). + `notifications/tools/list_changed` on attach/detach is **designed but not yet implemented** — no code + emits it today (grep `list_changed` → 0 hits in the gateway/core crates); it is tracked as P2b in + [reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md). **Superseded as the default:** the generic design ([reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md)) drops static-advertise as the default in favour of dynamic `tools/list` forwarding + `list_changed`, keeping static-advertise as an opt-in for the browser case. From 1c35aaa22a743b6aaac762710647e90512e39501 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 25 Jul 2026 06:10:20 +0800 Subject: [PATCH 42/46] refactor(acp): compound-key (channel_id,server_id) tunnel registry + rename BrowserTunnel->AcpMcpTunnel (P1, Fork A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving refactor toward generic multi-server MCP-over-ACP (reverse-MCP ADR §6). No functional change for the single browser server. - AcpTunnelRegistry: HashMap -> HashMap<(String,String),_>; register under the client-declared srv.id; evict all (channel_id,*) on teardown. - Core trait BrowserTunnel -> AcpMcpTunnel; call() gains a server_id param. - Read side (Fork A): the single-browser proxy + bridge pass an empty server_id sentinel; RootBrowserTunnel resolves the sole tunnel on the channel (errors if ambiguous). Real per-server read-side routing (bridge-frame server_id) is deferred to P2. Gate: build --features acp, clippy --workspace -D warnings (+unified), test -p openab-gateway --features acp — all green (307 passed). Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/pool.rs | 4 +- crates/openab-core/src/mcp_proxy.rs | 55 ++++++++++++------- .../openab-gateway/src/adapters/acp_server.rs | 30 +++++----- src/browser_tunnel.rs | 35 +++++++++--- src/main.rs | 2 +- 5 files changed, 82 insertions(+), 44 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 256769280..215f93b1d 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -56,7 +56,7 @@ pub struct SessionPool { /// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the /// root. `None` = no browser wiring (tool calls report not-connected). #[cfg(feature = "acp-mcp")] - browser_tunnel: Option>, + browser_tunnel: Option>, } type CancelHandle = (Arc>, String); @@ -211,7 +211,7 @@ impl SessionPool { #[cfg(feature = "acp-mcp")] pub fn with_browser_tunnel( mut self, - tunnel: Option>, + tunnel: Option>, ) -> Self { self.browser_tunnel = tunnel; self diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index bb329970a..75aff634f 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -28,13 +28,19 @@ use std::sync::Arc; /// proxy here. Keeping the trait in core with the impl in root preserves the core/gateway /// sibling independence, matching the existing `ChatAdapter` pattern. #[async_trait::async_trait] -pub trait BrowserTunnel: Send + Sync { - /// Forward an inner MCP request (e.g. `tools/call`) to the browser session identified by - /// `channel_id` and return the inner MCP result payload. Err if no browser is currently - /// attached to that session. +pub trait AcpMcpTunnel: Send + Sync { + /// Forward an inner MCP request (e.g. `tools/call`) to the client MCP server identified by + /// `(channel_id, server_id)` and return the inner MCP result payload. Err if no matching + /// tunnel is currently attached to that session. + /// + /// `server_id` selects among multiple `type:acp` servers on one session (compound-key + /// registry, P1). During the single-browser transition an empty `server_id` is a sentinel + /// meaning "the sole tunnel on this channel" — the proxy/bridge callers don't yet know the + /// client-declared id at spawn time (real per-server routing lands in P2). async fn call( &self, channel_id: &str, + server_id: &str, method: &str, params: Option, ) -> Result; @@ -100,11 +106,11 @@ pub struct ProxyHandler { channel_id: String, /// Bridge to that session's browser tunnel; `None` when no browser is attached (or the /// process has no tunnel wiring). A call while `None` reports "browser not connected" (D4). - tunnel: Option>, + tunnel: Option>, } impl ProxyHandler { - pub fn new(channel_id: String, tunnel: Option>) -> Self { + pub fn new(channel_id: String, tunnel: Option>) -> Self { Self { channel_id, tunnel } } @@ -122,8 +128,10 @@ impl ProxyHandler { )); }; let params = json!({ "name": name, "arguments": arguments }); + // Empty server_id sentinel (Fork A): this single-browser proxy doesn't know the + // client-declared server id; RootBrowserTunnel resolves the sole tunnel on the channel. let result = tunnel - .call(&self.channel_id, "tools/call", Some(params)) + .call(&self.channel_id, "", "tools/call", Some(params)) .await .map_err(|e| McpError::internal_error(e, None))?; serde_json::from_value(result) @@ -194,7 +202,7 @@ async fn require_bearer( /// cancelled. pub async fn spawn_mcp_server( channel_id: String, - tunnel: Option>, + tunnel: Option>, bearer: String, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result { @@ -233,7 +241,7 @@ pub async fn spawn_mcp_server( pub async fn start_session_server( channel_id: &str, workdir: &str, - tunnel: Option>, + tunnel: Option>, ) -> std::io::Result<(std::net::SocketAddr, tokio_util::sync::CancellationToken)> { let bearer = uuid::Uuid::new_v4().to_string(); let ct = tokio_util::sync::CancellationToken::new(); @@ -399,7 +407,7 @@ pub fn browser_socket_path() -> std::path::PathBuf { // A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim // (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests // tagged with its own `channel_id` (from the OPENAB_BROWSER_CHANNEL env it inherits); core routes -// `tools/call` to that session's BrowserTunnel. This is the stable, variant-agnostic replacement +// `tools/call` to that session's AcpMcpTunnel. This is the stable, variant-agnostic replacement // for the per-session HTTP proxy (Option C). Wire = newline-delimited JSON, one frame per line: // bridge → core : {"channel_id": "...", "request": } // core → bridge : (omitted for notifications) @@ -420,7 +428,7 @@ fn mcp_error(id: Value, code: i64, message: &str) -> Value { pub(crate) async fn dispatch_browser_mcp( channel_id: &str, request: &Value, - tunnel: &Option>, + tunnel: &Option>, ) -> Option { // A JSON-RPC notification has no `id` → no response. let id = request.get("id").cloned()?; @@ -439,8 +447,11 @@ pub(crate) async fn dispatch_browser_mcp( mcp_result(id, json!({ "tools": tools })) } "tools/call" => match tunnel { + // Empty server_id sentinel (Fork A): the bridge frame carries no server id yet; + // RootBrowserTunnel resolves the sole tunnel on the channel. Real per-server routing + // (server_id in the frame) lands in P2. Some(t) => match t - .call(channel_id, "tools/call", request.get("params").cloned()) + .call(channel_id, "", "tools/call", request.get("params").cloned()) .await { Ok(v) => mcp_result(id, v), @@ -462,7 +473,7 @@ pub(crate) async fn dispatch_browser_mcp( /// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. pub async fn serve_browser_socket( path: std::path::PathBuf, - tunnel: Option>, + tunnel: Option>, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result<()> { let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run @@ -496,14 +507,14 @@ pub async fn serve_browser_socket( /// tokio-util dependency is needed. pub async fn serve_browser_socket_forever( path: std::path::PathBuf, - tunnel: Option>, + tunnel: Option>, ) -> std::io::Result<()> { serve_browser_socket(path, tunnel, tokio_util::sync::CancellationToken::new()).await } async fn handle_browser_conn( stream: tokio::net::UnixStream, - tunnel: Option>, + tunnel: Option>, ) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let (read_half, mut write_half) = stream.into_split(); @@ -535,7 +546,7 @@ async fn handle_browser_conn( mod tests { use super::{ browser_tools, dispatch_browser_mcp, parse_browser_mode, serve_browser_socket, - spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, BrowserTunnel, + spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, AcpMcpTunnel, ProxyHandler, }; @@ -553,14 +564,16 @@ mod tests { struct MockTunnel; #[async_trait::async_trait] - impl BrowserTunnel for MockTunnel { + impl AcpMcpTunnel for MockTunnel { async fn call( &self, channel_id: &str, + server_id: &str, method: &str, _params: Option, ) -> Result { assert_eq!(channel_id, "acp_x"); + assert_eq!(server_id, ""); // proxy passes the empty sentinel (Fork A) assert_eq!(method, "tools/call"); Ok(serde_json::json!({"content": [{"type": "text", "text": "clicked"}]})) } @@ -588,10 +601,11 @@ mod tests { result: serde_json::Value, } #[async_trait::async_trait] - impl BrowserTunnel for RecordTunnel { + impl AcpMcpTunnel for RecordTunnel { async fn call( &self, channel_id: &str, + _server_id: &str, method: &str, _params: Option, ) -> Result { @@ -602,10 +616,11 @@ mod tests { } struct ErrTunnel; #[async_trait::async_trait] - impl BrowserTunnel for ErrTunnel { + impl AcpMcpTunnel for ErrTunnel { async fn call( &self, _c: &str, + _s: &str, _m: &str, _p: Option, ) -> Result { @@ -615,7 +630,7 @@ mod tests { fn req(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) } - fn arc_tunnel(t: T) -> Option> { + fn arc_tunnel(t: T) -> Option> { Some(std::sync::Arc::new(t)) } diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 2be85c5ef..581bf0df3 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -323,11 +323,13 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } -/// Registry of open MCP-over-ACP tunnels: channel_id → `TunnelHandle`. The gateway inserts a -/// handle once it has `mcp/connect`ed to a session's browser extension server; the core MCP -/// proxy looks one up to route a tool call to the right browser (T5.3). Same std::sync::Mutex -/// rationale as `AcpReplyRegistry`. -pub type AcpTunnelRegistry = Arc>>; +/// Registry of open MCP-over-ACP tunnels: `(channel_id, server_id)` → `TunnelHandle`. The +/// gateway inserts a handle once it has `mcp/connect`ed to a session's declared `type:acp` +/// server; the core MCP proxy looks one up to route a tool call to the right server (T5.3). +/// Keyed by the compound `(channel_id, server_id)` (P1) so one session can carry several +/// `type:acp` servers without collision; eviction drops all `(channel_id, *)` on teardown. Same +/// std::sync::Mutex rationale as `AcpReplyRegistry`. +pub type AcpTunnelRegistry = Arc>>; pub fn new_tunnel_registry() -> AcpTunnelRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) @@ -725,8 +727,8 @@ async fn establish_and_register_tunnel( registry .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id.clone(), handle); - info!(channel_id = %channel_id, "ACP: browser tunnel registered — extension attached"); + .insert((channel_id.clone(), acp_id.clone()), handle); + info!(channel_id = %channel_id, server_id = %acp_id, "ACP: browser tunnel registered — extension attached"); Ok(()) } @@ -1153,9 +1155,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } if let Some(ref registry) = state.acp_tunnel_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); - for cid in &channel_ids { - reg.remove(cid); - } + // Compound-key registry (P1): drop every `(channel_id, *)` tunnel for the closed session. + reg.retain(|(cid, _), _| !channel_ids.contains(cid)); } debug!( connection = %connection_id, @@ -2209,7 +2210,7 @@ mod acp_requests { } #[tokio::test] - async fn establish_tunnel_registers_handle_under_channel_id() { + async fn establish_tunnel_registers_handle_under_channel_and_server_id() { let pending = new_pending(); let next_id = Arc::new(AtomicU64::new(1)); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -2239,8 +2240,11 @@ mod acp_requests { ext.await.unwrap(); assert!( - registry.lock().unwrap().contains_key("acp_abc"), - "a TunnelHandle must be registered under the session channel_id" + registry + .lock() + .unwrap() + .contains_key(&("acp_abc".to_string(), "srv-1".to_string())), + "a TunnelHandle must be registered under (channel_id, server_id)" ); } } diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index e11ab5efa..1c46488ad 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -1,10 +1,11 @@ -//! Root-side bridge implementing the core `BrowserTunnel` trait (D6-a'). Reads the gateway's -//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the browser attached -//! to a given `channel_id`. This lives in the root binary — the only place that depends on -//! BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), preserving the two -//! crates' sibling independence (mirroring the existing `ChatAdapter` glue at the root). +//! Root-side bridge implementing the core `AcpMcpTunnel` trait (D6-a'). Reads the gateway's +//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the client MCP server +//! attached to a given `(channel_id, server_id)`. This lives in the root binary — the only place +//! that depends on BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), +//! preserving the two crates' sibling independence (mirroring the existing `ChatAdapter` glue at +//! the root). -use openab_core::mcp_proxy::BrowserTunnel; +use openab_core::mcp_proxy::AcpMcpTunnel; use openab_gateway::adapters::acp_server::AcpTunnelRegistry; use serde_json::Value; @@ -19,17 +20,35 @@ impl RootBrowserTunnel { } #[async_trait::async_trait] -impl BrowserTunnel for RootBrowserTunnel { +impl AcpMcpTunnel for RootBrowserTunnel { async fn call( &self, channel_id: &str, + server_id: &str, method: &str, params: Option, ) -> Result { // Clone the handle out under the lock; never hold the std mutex across `.await`. let handle = { let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); - reg.get(channel_id).cloned() + if server_id.is_empty() { + // Fork A sentinel: the single-browser proxy/bridge doesn't know the client- + // declared server id, so resolve the SOLE tunnel registered for this channel. + // Ambiguous (0 or >1) is an error — real per-server routing arrives in P2. + let mut matches = reg.iter().filter(|((c, _), _)| c == channel_id); + match (matches.next(), matches.next()) { + (Some((_, h)), None) => Some(h.clone()), + (None, _) => None, + (Some(_), Some(_)) => { + return Err(format!( + "multiple MCP servers attached to session {channel_id}; a server_id is required to disambiguate" + )); + } + } + } else { + reg.get(&(channel_id.to_string(), server_id.to_string())) + .cloned() + } }; match handle { Some(h) => h.mcp_message(method, params, 30).await, diff --git a/src/main.rs b/src/main.rs index bc894e96f..cd97b1ac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -404,7 +404,7 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let browser_tunnel: Arc = Arc::new( + let browser_tunnel: Arc = Arc::new( browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), ); #[cfg(feature = "acp")] From 487a05a58a57824dd830bbda2d60869a26c5f2b2 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 24 Jul 2026 22:08:35 -0400 Subject: [PATCH 43/46] chore: regenerate Cargo.lock for merged deps --- Cargo.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a05e04d2a..c7b795033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,6 +2533,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sigv4", + "axum", "base64", "bytes", "chrono", @@ -2550,17 +2551,20 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest 0.12.28", + "rmcp", "rpassword", "rustls 0.22.4", "serde", "serde_json", "serenity", "sha2 0.10.9", + "subtle", "tar", "tempfile", "tokio", "tokio-rustls 0.25.0", "tokio-tungstenite 0.21.0", + "tokio-util", "toml", "toml_edit", "tracing", From c2a7c6a91672330cfed274de96fcf2b0dcf8c866 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 24 Jul 2026 22:21:04 -0400 Subject: [PATCH 44/46] fix(mcp-proxy): register browser server in kiro per-agent configs (--agent mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When kiro-cli runs with --agent — as every OAB bot deployment does — the MCP server list comes from .kiro/agents/.json and tools are gated by that file's default-deny allowedTools, NOT from .kiro/settings/mcp.json (verified live on the b2 fleet deployment; see docs/gmail-native.md 'Kiro CLI gotcha'). Without this, browser tools are invisible to exactly the deployments this feature targets. - merge_kiro_agent_configs: merge the openab-browser entry into every .kiro/agents/*.json and add @openab-browser to allowedTools; agent files carry unrelated config, so unparseable files are skipped, never clobbered (unlike the settings writer); macOS ._* droppings ignored; idempotent; 0600 (proxy entries carry a live bearer). - cleanup_kiro_agent_configs on session evict: remove the entry and revoke the allowlist grant only when the URL is still ours, preserving a concurrent session's live entry (same rule as the settings cleanup). - Wired into both the per-session proxy writer and the static Option C bridge writer; 4 new tests. --- crates/openab-core/src/mcp_proxy.rs | 253 +++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 75aff634f..4cd0ed371 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -280,6 +280,10 @@ pub async fn start_session_server( write_private(cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; } + // kiro `--agent ` deployments read the agent file, not settings/mcp.json — + // merge (and allowlist) there too, or the tools never reach OAB bot agents. + merge_kiro_agent_configs(workdir, &entry).await?; + // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry // (with its live bearer) from each config so a stale credential doesn't linger. Only remove it // if it still points at OUR addr — a concurrent/reconnected session may have already replaced @@ -287,8 +291,10 @@ pub async fn start_session_server( let cleanup_paths = cfg_paths.to_vec(); let cleanup_url = our_url; let cleanup_ct = ct.clone(); + let cleanup_workdir = workdir.to_string(); tokio::spawn(async move { cleanup_ct.cancelled().await; + cleanup_kiro_agent_configs(&cleanup_workdir, &cleanup_url).await; for cleanup_path in &cleanup_paths { let Ok(bytes) = tokio::fs::read(cleanup_path).await else { continue; @@ -327,6 +333,102 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } +/// Merge the `openab-browser` entry into every kiro **per-agent** config +/// (`/.kiro/agents/*.json`). When kiro-cli runs with `--agent ` +/// — as every OAB bot deployment does — it reads its MCP server list from the +/// agent file, NOT from `.kiro/settings/mcp.json`, and gates tools through the +/// file's `allowedTools` allowlist (verified live on the b2 fleet deployment; +/// see docs/gmail-native.md "Kiro CLI gotcha"). Without this, browser tools +/// are invisible to exactly the deployments this feature targets. +/// +/// Unlike the settings-file writer, agent files carry unrelated config +/// (model, description, allowlists), so an unparseable file is SKIPPED — +/// never clobbered with a fresh object. macOS metadata droppings +/// (`._*.json`) are ignored. Missing agents dir = no-op. +async fn merge_kiro_agent_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return Ok(()); // no agents dir → nothing runs with --agent here + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; // agent files carry model/allowlists — never clobber + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + let mut changed = false; + if cfg["mcpServers"]["openab-browser"] != *entry { + cfg["mcpServers"]["openab-browser"] = entry.clone(); + changed = true; + } + // `allowedTools` is a default-deny allowlist: adding the server + // without allowlisting it leaves every browser tool blocked. + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + if !allowed.iter().any(|v| v.as_str() == Some("@openab-browser")) { + allowed.push(json!("@openab-browser")); + changed = true; + } + } + if changed { + write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + +/// Session-evict counterpart of [`merge_kiro_agent_configs`]: strip the +/// now-dead `openab-browser` entry (and its `allowedTools` grant) from every +/// kiro agent file — but only when the entry still points at OUR `url`, so a +/// concurrent/reconnected session's live entry is never clobbered (same rule +/// as the settings-file cleanup). Static (url-less) bridge entries are left +/// alone. +async fn cleanup_kiro_agent_configs(workdir: &str, url: &str) { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return; + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(url); + if !still_ours { + continue; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + allowed.retain(|v| v.as_str() != Some("@openab-browser")); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(&path, &out).await; + } + } +} + /// Write the STATIC, write-once `openab-browser` bridge entry into each colocated CLI's mcp.json /// (Option C, bridge mode). Unlike the per-session HTTP proxy config, this carries no port/bearer /// — it is the same `{command:"openab", args:["browser-bridge"]}` for every session, so it can be @@ -363,6 +465,9 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; } } + // kiro `--agent` deployments read agent files, not settings/mcp.json (same + // gap as the proxy-mode writer). The static entry is idempotent there too. + merge_kiro_agent_configs(workdir, &entry).await?; Ok(()) } @@ -545,11 +650,153 @@ async fn handle_browser_conn( #[cfg(test)] mod tests { use super::{ - browser_tools, dispatch_browser_mcp, parse_browser_mode, serve_browser_socket, - spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, AcpMcpTunnel, - ProxyHandler, + browser_tools, cleanup_kiro_agent_configs, dispatch_browser_mcp, + merge_kiro_agent_configs, parse_browser_mode, serve_browser_socket, spawn_mcp_server, + start_session_server, write_bridge_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, }; + /// Unique throwaway workdir with a `.kiro/agents/` tree. + async fn tmp_workdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "oab-mcp-proxy-test-{tag}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + tokio::fs::create_dir_all(dir.join(".kiro").join("agents")) + .await + .unwrap(); + dir + } + + #[tokio::test] + async fn kiro_agent_merge_adds_server_and_allowlist_preserving_the_rest() { + let wd = tmp_workdir("merge").await; + let agent = wd.join(".kiro/agents/terra.json"); + tokio::fs::write( + &agent, + serde_json::to_vec_pretty(&serde_json::json!({ + "name": "terra", + "model": "gpt-5.6-terra", + "mcpServers": { "github": { "url": "http://ghpool:8080/mcp" } }, + "allowedTools": ["@builtin", "@github"] + })) + .unwrap(), + ) + .await + .unwrap(); + let entry = serde_json::json!({ + "url": "http://127.0.0.1:45678/mcp", + "headers": { "Authorization": "Bearer tok" } + }); + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + let cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(cfg["mcpServers"]["openab-browser"], entry); + assert_eq!( + cfg["mcpServers"]["github"]["url"], "http://ghpool:8080/mcp", + "pre-existing servers must be preserved" + ); + assert_eq!(cfg["model"], "gpt-5.6-terra", "unrelated fields preserved"); + let allowed = cfg["allowedTools"].as_array().unwrap(); + assert!( + allowed.iter().any(|v| v == "@openab-browser"), + "allowedTools is default-deny — the server must be allowlisted: {allowed:?}" + ); + // Idempotent: second merge changes nothing (byte-stable allowlist). + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + let cfg2: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(cfg, cfg2); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_merge_skips_unparseable_and_metadata_files() { + let wd = tmp_workdir("skip").await; + let junk = wd.join(".kiro/agents/broken.json"); + tokio::fs::write(&junk, b"{not json").await.unwrap(); + let meta = wd.join(".kiro/agents/._terra.json"); + tokio::fs::write(&meta, b"\x00\x05\x16\x07").await.unwrap(); + let entry = serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }); + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + assert_eq!( + tokio::fs::read(&junk).await.unwrap(), + b"{not json", + "unparseable agent files must be skipped, never clobbered" + ); + assert_eq!(tokio::fs::read(&meta).await.unwrap(), b"\x00\x05\x16\x07"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_merge_without_agents_dir_is_noop() { + let wd = std::env::temp_dir().join(format!( + "oab-mcp-proxy-test-noop-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + tokio::fs::create_dir_all(&wd).await.unwrap(); + merge_kiro_agent_configs( + wd.to_str().unwrap(), + &serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }), + ) + .await + .unwrap(); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_cleanup_removes_only_our_url_and_its_grant() { + let wd = tmp_workdir("cleanup").await; + let ours = wd.join(".kiro/agents/ours.json"); + tokio::fs::write( + &ours, + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:1111/mcp" } }, + "allowedTools": ["@builtin", "@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + let foreign = wd.join(".kiro/agents/foreign.json"); + tokio::fs::write( + &foreign, + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:2222/mcp" } }, + "allowedTools": ["@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + cleanup_kiro_agent_configs(wd.to_str().unwrap(), "http://127.0.0.1:1111/mcp").await; + let ours_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&ours).await.unwrap()).unwrap(); + assert!(ours_cfg["mcpServers"]["openab-browser"].is_null()); + assert!( + !ours_cfg["allowedTools"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "@openab-browser"), + "the stale allowlist grant must be revoked with the entry" + ); + let foreign_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&foreign).await.unwrap()).unwrap(); + assert_eq!( + foreign_cfg["mcpServers"]["openab-browser"]["url"], "http://127.0.0.1:2222/mcp", + "a concurrent session's live entry must never be clobbered" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + #[test] fn browser_mode_defaults_to_proxy_and_opts_into_bridge() { assert_eq!(parse_browser_mode(None), BrowserMode::Proxy); From bf37d25e1f63a04ac44f2a887fa22b66f58e4f86 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 25 Jul 2026 00:23:55 -0400 Subject: [PATCH 45/46] feat(mcp): browser capabilities through the session-aware facade (Facade mode, default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes browser tools through the OAB MCP Facade as a session-aware in-process capability source (openab-mcp #1454), replacing per-session proxy servers as the default transport. Proxy and Option C bridge modes are unchanged and remain explicit opt-outs (OPENAB_BROWSER_MODE). - src/browser_source.rs: CapabilitySource over the existing AcpMcpTunnel (requires_session; D4 static-advertise; tunnel errors surface as MCP error results); FacadeRegistrar adapts the facade's SessionTokens to core's new SessionTokenRegistrar hook (core stays openab-mcp-free). - core mcp_proxy: BrowserMode::Facade (new default; runtime fallback to Proxy when no facade is serving), write_facade_mcp_config — a static, write-once 'openab' entry whose Authorization references ${OPENAB_SESSION_TOKEN}; the per-session secret rides the agent process env instead of config files, eliminating the shared-workdir clobber class entirely (incl. kiro --agent files + @openab allowlist). - pool: with_facade_sessions wiring; mints/injects the token per spawn, revokes via the same DropGuard plumbing proxy mode uses. - main: facade constructed with the BrowserSource; one listener, one discovery surface (search_capabilities/execute_capability). - rmcp re-exported from openab-mcp for source implementors. - docs: facade-mode section in the browser setup guide. --- crates/openab-core/src/acp/pool.rs | 77 +++++++++++++++++- crates/openab-core/src/mcp_proxy.rs | 121 ++++++++++++++++++++++++++-- crates/openab-mcp/src/lib.rs | 4 + docs/browser-mcp-agent-setup.md | 24 ++++++ src/browser_source.rs | 96 ++++++++++++++++++++++ src/main.rs | 46 +++++++++-- 6 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 src/browser_source.rs diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 215f93b1d..2d8d24e8d 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -57,6 +57,10 @@ pub struct SessionPool { /// root. `None` = no browser wiring (tool calls report not-connected). #[cfg(feature = "acp-mcp")] browser_tunnel: Option>, + #[cfg(feature = "acp-mcp")] + session_registrar: Option>, + #[cfg(feature = "acp-mcp")] + facade_url: Option, } type CancelHandle = (Arc>, String); @@ -203,6 +207,10 @@ impl SessionPool { default_config_options, #[cfg(feature = "acp-mcp")] browser_tunnel: None, + #[cfg(feature = "acp-mcp")] + session_registrar: None, + #[cfg(feature = "acp-mcp")] + facade_url: None, } } @@ -217,6 +225,22 @@ impl SessionPool { self } + /// Wire the facade session-token registrar + facade URL (Facade mode, + /// set by the root when `[mcp]` is running). With both present, browser + /// capabilities route through the facade: the pool mints one token per + /// session, injects it as `OPENAB_SESSION_TOKEN` in the agent process + /// env, and writes the static facade MCP entry once per workdir. + #[cfg(feature = "acp-mcp")] + pub fn with_facade_sessions( + mut self, + registrar: Option>, + facade_url: Option, + ) -> Self { + self.session_registrar = registrar; + self.facade_url = facade_url; + self + } + fn load_mapping(path: &Path) -> HashMap { match std::fs::read_to_string(path) { Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { @@ -367,10 +391,47 @@ impl SessionPool { // Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP // server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The // returned guard cancels that server when this connection is dropped (any evict path). + // Facade mode: mint the per-session token (returned env var rides the + // agent spawn below) and write the static facade entry once. Falls + // back to Proxy mode when the root wired no registrar (no [mcp]). + #[cfg(feature = "acp-mcp")] + let mut session_token: Option = None; #[cfg(feature = "acp-mcp")] let mcp_guard: Option = if let Some(channel_id) = thread_id.strip_prefix("acp:") { - match crate::mcp_proxy::browser_mode() { + let mode = match crate::mcp_proxy::browser_mode() { + crate::mcp_proxy::BrowserMode::Facade + if self.session_registrar.is_none() || self.facade_url.is_none() => + { + crate::mcp_proxy::BrowserMode::Proxy + } + m => m, + }; + match mode { + crate::mcp_proxy::BrowserMode::Facade => { + // unwraps guarded by the fallback arm above + let registrar = self.session_registrar.as_ref().unwrap(); + let facade_url = self.facade_url.as_ref().unwrap(); + if let Err(e) = + crate::mcp_proxy::write_facade_mcp_config(&effective_workdir, facade_url) + .await + { + warn!(thread_id, error = %e, "failed to write facade mcp config"); + } + session_token = Some(registrar.mint(channel_id)); + info!(thread_id, "session token minted for facade browser capabilities"); + // Revoke on evict/replace: piggyback the same DropGuard + // plumbing proxy mode uses for its server teardown. + let ct = tokio_util::sync::CancellationToken::new(); + let child = ct.child_token(); + let registrar = registrar.clone(); + let chan = channel_id.to_string(); + tokio::spawn(async move { + child.cancelled().await; + registrar.revoke(&chan); + }); + Some(ct.drop_guard()) + } // Bridge mode (Option C): the agent's static mcp.json points at `openab // browser-bridge`, which dials the pod-wide socket server (started at boot). // Just ensure the write-once config exists — no per-session server/guard. @@ -408,11 +469,23 @@ impl SessionPool { // Build the replacement connection outside the state lock so one stuck // initialization does not block all unrelated sessions. + #[cfg(feature = "acp-mcp")] + let spawn_env: std::collections::HashMap = { + let mut env = self.config.env.clone(); + if let Some(tok) = &session_token { + // The static facade MCP entry references ${OPENAB_SESSION_TOKEN}; + // the value lives only in this agent process's environment. + env.insert("OPENAB_SESSION_TOKEN".to_string(), tok.clone()); + } + env + }; + #[cfg(not(feature = "acp-mcp"))] + let spawn_env = self.config.env.clone(); let mut new_conn = AcpConnection::spawn( &self.config.command, &self.config.args, &effective_workdir, - &self.config.env, + &spawn_env, &self.config.inherit_env, thread_id.strip_prefix("acp:"), ) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 4cd0ed371..c38c55513 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -48,7 +48,7 @@ pub trait AcpMcpTunnel: Send + Sync { /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. -pub(crate) fn browser_tools() -> Vec { +pub fn browser_tools() -> Vec { vec![ Tool::new( "browser.click", @@ -471,11 +471,114 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { Ok(()) } +/// Write the STATIC, write-once `openab` facade entry into each colocated CLI's MCP config +/// (Facade mode). Like the Option C bridge entry it is byte-identical for every session — +/// the per-session secret is NOT in the file: the entry references the +/// `OPENAB_SESSION_TOKEN` environment variable, which the pool injects into each spawned +/// agent process (config-var expansion is exactly how deployed agents already reference +/// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token +/// dies with the agent process and its registry entry. +pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io::Result<()> { + let entry = json!({ + "url": facade_url, + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + }); + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + // Publish under "openab" (the facade), not "openab-browser": the agent + // reaches ALL facade capabilities through this one entry. + if cfg["mcpServers"]["openab"] != entry { + cfg["mcpServers"]["openab"] = entry.clone(); + tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + } + } + // kiro `--agent` deployments read agent files, not settings/mcp.json. + merge_kiro_agent_facade_configs(workdir, &entry).await?; + Ok(()) +} + +/// Facade-mode sibling of [`merge_kiro_agent_configs`]: merges the static +/// `openab` facade entry + `@openab` allowlist grant into every +/// `.kiro/agents/*.json`. Same never-clobber rules; nothing to clean up on +/// evict (the entry is static and the token lives in the process env). +async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return Ok(()); + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + let mut changed = false; + if cfg["mcpServers"]["openab"] != *entry { + cfg["mcpServers"]["openab"] = entry.clone(); + changed = true; + } + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + if !allowed.iter().any(|v| v.as_str() == Some("@openab")) { + allowed.push(json!("@openab")); + changed = true; + } + } + if changed { + write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + +/// Broker-side session credential hook (Facade mode). Implemented by the root +/// (closing over the facade's `SessionTokens` registry — core stays free of +/// the openab-mcp dependency); the pool calls it at session spawn/evict. +pub trait SessionTokenRegistrar: Send + Sync { + /// Mint (or re-mint) the token for `channel_id`; returns the value the + /// pool injects as `OPENAB_SESSION_TOKEN` in the agent's environment. + fn mint(&self, channel_id: &str) -> String; + /// Revoke every token for `channel_id` (session evicted/replaced). + fn revoke(&self, channel_id: &str); +} + /// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into /// the stdio bridge; anything else (including unset) keeps the per-session HTTP proxy — the safe /// default during rollout, so existing Cursor/Kiro browser control is unchanged until flipped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserMode { + /// Browser tools served through the OAB MCP Facade as a session-aware + /// in-process capability source (one listener, session identity via + /// broker-minted tokens). The default when the facade is running; + /// falls back to `Proxy` when it is not (no `[mcp]` in config). + Facade, + /// Per-session loopback HTTP MCP server + dynamic config (the original + /// default; explicit opt-out from facade routing). Proxy, Bridge, } @@ -489,7 +592,8 @@ impl BrowserMode { fn parse_browser_mode(s: Option<&str>) -> BrowserMode { match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { Some("bridge") => BrowserMode::Bridge, - _ => BrowserMode::Proxy, + Some("proxy") => BrowserMode::Proxy, + _ => BrowserMode::Facade, } } @@ -798,15 +902,20 @@ mod tests { } #[test] - fn browser_mode_defaults_to_proxy_and_opts_into_bridge() { - assert_eq!(parse_browser_mode(None), BrowserMode::Proxy); - assert_eq!(parse_browser_mode(Some("")), BrowserMode::Proxy); + fn browser_mode_defaults_to_facade_with_proxy_and_bridge_opt_outs() { + // Facade is the default: browser tools ride the OAB MCP Facade as a + // session-aware source (falls back to Proxy at runtime when no + // facade is serving — see the pool's mode fallback). + assert_eq!(parse_browser_mode(None), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some("")), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Facade); + // Explicit opt-outs keep their exact prior semantics. assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); - assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Proxy); assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Bridge); assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Bridge); assert!(BrowserMode::Bridge.is_bridge()); assert!(!BrowserMode::Proxy.is_bridge()); + assert!(!BrowserMode::Facade.is_bridge()); } struct MockTunnel; diff --git a/crates/openab-mcp/src/lib.rs b/crates/openab-mcp/src/lib.rs index 53cd3fca2..b2ce17814 100644 --- a/crates/openab-mcp/src/lib.rs +++ b/crates/openab-mcp/src/lib.rs @@ -20,6 +20,10 @@ //! original `openab-agent` layout so the moved code's `crate::` paths and //! the agent's `crate::…` re-export shims stay stable. +/// Re-exported for `CapabilitySource` implementors in dependent crates +/// (the trait surface names `rmcp::model::Tool`). +pub use rmcp; + pub mod acp; pub mod auth; pub mod llm; diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index bcf3ec389..fce45231e 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -71,3 +71,27 @@ kiro-cli mcp list Gateway log confirms the extension side: `ACP: browser tunnel registered — extension attached`. + +## Facade mode (default when `[mcp]` is enabled) + +With the OAB MCP Facade running (`[mcp]` in `config.toml`), browser tools are +served as a **session-aware in-process capability source** of the facade +instead of per-session proxy servers: + +- **One listener** (the facade's, e.g. `127.0.0.1:8848/mcp`) — no per-session + ports, no per-session config rewrites. +- **Identity**: the pool mints one token per chat session and injects it as + `OPENAB_SESSION_TOKEN` into the agent process environment; the (static, + write-once) MCP config entry references it as + `"Authorization": "Bearer ${OPENAB_SESSION_TOKEN}"`. Tokens are revoked on + session evict; calls route to that session's browser via the same + `channel_id` tunnel contract as proxy mode. +- **Discovery**: agents find browser tools through `search_capabilities` + alongside every other facade capability, and execute them via + `execute_capability`. + +Mode selection (`OPENAB_BROWSER_MODE`): unset/`facade` → facade routing when +the facade is serving, with automatic fallback to `proxy` when it is not +(no `[mcp]` section); `proxy` → force the original per-session loopback +servers; `bridge` → Option C stdio bridge. Proxy and bridge behavior is +unchanged. diff --git a/src/browser_source.rs b/src/browser_source.rs new file mode 100644 index 000000000..959a95fc8 --- /dev/null +++ b/src/browser_source.rs @@ -0,0 +1,96 @@ +//! Browser capability source (Facade mode): serves the browser tool set as a +//! **session-aware in-process capability source** of the OAB MCP Facade +//! (`openab_mcp::mcp::sources`), replacing the per-session loopback proxy as +//! the default transport. Identity comes from the broker-minted session +//! token (`OPENAB_SESSION_TOKEN` in the agent's env → `Authorization` header +//! → `SessionCtx`), and calls route into the same MCP-over-ACP tunnel the +//! proxy used — `channel_id` semantics unchanged. +//! +//! Root-hosted because it needs both worlds: `openab_mcp`'s source trait and +//! `openab_core`'s tunnel bridge (core and the mcp crate stay independent). + +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use openab_core::mcp_proxy::AcpMcpTunnel; +use openab_mcp::mcp::sources::{CapabilitySource, SessionCtx}; +use serde_json::{json, Map, Value}; + +/// Facade capability source backed by the browser MCP-over-ACP tunnel. +pub struct BrowserSource { + tunnel: Arc, +} + +impl BrowserSource { + pub fn new(tunnel: Arc) -> Self { + Self { tunnel } + } +} + +#[async_trait::async_trait] +impl CapabilitySource for BrowserSource { + fn provider(&self) -> &str { + "openab-browser" + } + + /// D4 static-advertise (unchanged from proxy mode): the tool set is + /// constant regardless of extension attachment — a call while + /// disconnected returns a "browser not connected" error result rather + /// than catalog flapping. + fn tools(&self, _ctx: Option<&SessionCtx>) -> Vec { + openab_core::mcp_proxy::browser_tools() + } + + async fn call( + &self, + ctx: Option<&SessionCtx>, + tool: &str, + args: &Map, + ) -> Result<(Value, bool)> { + // requires_session() guarantees ctx in practice; defend anyway. + let ctx = ctx.ok_or_else(|| anyhow!("browser capabilities require a session token"))?; + let params = json!({ "name": tool, "arguments": args }); + // Empty server_id sentinel (Fork A) — same routing contract as the + // per-session proxy: RootBrowserTunnel resolves the sole tunnel on + // the channel. + match self + .tunnel + .call(&ctx.channel_id, "", "tools/call", Some(params)) + .await + { + // The tunnel returns the inner MCP CallToolResult payload; pass + // it through and mirror its own isError flag. + Ok(result) => { + let is_error = result + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok((result, is_error)) + } + // Tunnel-level failure (no extension attached, session gone): + // an error *result* — the agent gets an actionable message, the + // facade dispatch itself did not fault. + Err(msg) => Ok(( + json!({ "content": [{ "type": "text", "text": msg }], "isError": true }), + true, + )), + } + } + + fn requires_session(&self) -> bool { + true + } +} + +/// Root-side adapter: exposes the facade's `SessionTokens` registry through +/// core's `SessionTokenRegistrar` hook (core cannot depend on openab-mcp). +pub struct FacadeRegistrar(pub openab_mcp::mcp::sources::SessionTokens); + +impl openab_core::mcp_proxy::SessionTokenRegistrar for FacadeRegistrar { + fn mint(&self, channel_id: &str) -> String { + self.0.mint(channel_id) + } + fn revoke(&self, channel_id: &str) { + self.0.revoke_channel(channel_id) + } +} diff --git a/src/main.rs b/src/main.rs index 910fece7c..1be81b680 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ mod unified_adapter; #[cfg(feature = "acp")] mod browser_tunnel; #[cfg(feature = "acp")] +mod browser_source; +#[cfg(feature = "acp")] mod browser_bridge; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; @@ -469,6 +471,10 @@ async fn main() -> anyhow::Result<()> { // session; the core MCP proxy reads it via the RootBrowserTunnel bridge below. #[cfg(feature = "acp")] let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); + #[cfg(feature = "acp")] + let browser_tunnel: Arc = Arc::new( + browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), + ); // OAB MCP Facade (`[mcp]` in config.toml — OAB MCP Adapter ADR §6.2): // serve the loopback Streamable HTTP MCP server in-process so any coding @@ -476,10 +482,29 @@ async fn main() -> anyhow::Result<()> { // http:///mcp. Absent section = no listener (backward compat). // A bind failure is fatal at startup (fail fast, like a bad platform // token) rather than a silently missing capability surface. + // + // Browser capabilities (Facade mode, default): registered as a + // session-aware in-process source — one listener, per-session identity + // via broker-minted tokens; no per-session proxy servers. + let facade_sessions = openab_mcp::mcp::sources::SessionTokens::new(); + #[allow(unused_mut)] + let mut facade_serving = false; if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); + let tokens = facade_sessions.clone(); + #[allow(unused_mut)] + let mut sources: Vec> = Vec::new(); + #[cfg(feature = "acp")] + if !openab_core::mcp_proxy::browser_mode().is_bridge() { + sources.push(Arc::new(browser_source::BrowserSource::new( + browser_tunnel.clone(), + ))); + } + facade_serving = true; tokio::spawn(async move { - if let Err(e) = openab_mcp::mcp::facade::serve_http(&listen).await { + if let Err(e) = + openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await + { tracing::error!(error = %format!("{e:#}"), listen, "OAB MCP facade exited"); std::process::exit(1); } @@ -495,11 +520,22 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let browser_tunnel: Arc = Arc::new( - browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), - ); - #[cfg(feature = "acp")] let pool_inner = pool_inner.with_browser_tunnel(Some(browser_tunnel.clone())); + // Facade mode session wiring: only when the facade is actually serving — + // otherwise the pool's mode fallback keeps the per-session proxy path. + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_facade_sessions( + facade_serving.then(|| { + Arc::new(browser_source::FacadeRegistrar(facade_sessions.clone())) + as Arc + }), + facade_serving.then(|| { + format!( + "http://{}/mcp", + cfg.mcp.as_ref().map(|m| m.listen.as_str()).unwrap_or("127.0.0.1:8848") + ) + }), + ); // Option C bridge mode: start the per-pod browser socket server once; the `openab // browser-bridge` shims each agent spawns dial it. Proxy mode (default) skips this. #[cfg(feature = "acp")] From 74e23f0eadd48cbfa7654754b9b8a3cb5868b880 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 25 Jul 2026 00:33:00 -0400 Subject: [PATCH 46/46] =?UTF-8?q?fix:=20facade=5Fserving=20is=20acp-only?= =?UTF-8?q?=20=E2=80=94=20derive=20it,=20don't=20flag=20it=20(default-feat?= =?UTF-8?q?ures=20-D=20warnings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1be81b680..622eac800 100644 --- a/src/main.rs +++ b/src/main.rs @@ -487,8 +487,9 @@ async fn main() -> anyhow::Result<()> { // session-aware in-process source — one listener, per-session identity // via broker-minted tokens; no per-session proxy servers. let facade_sessions = openab_mcp::mcp::sources::SessionTokens::new(); - #[allow(unused_mut)] - let mut facade_serving = false; + // Only read under the acp feature (pool facade wiring below). + #[cfg(feature = "acp")] + let facade_serving = cfg.mcp.is_some(); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); let tokens = facade_sessions.clone(); @@ -500,7 +501,6 @@ async fn main() -> anyhow::Result<()> { browser_tunnel.clone(), ))); } - facade_serving = true; tokio::spawn(async move { if let Err(e) = openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await