feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2) - #1447
feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2)#1447brettchien wants to merge 181 commits into
Conversation
…(§7) 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 <noreply@anthropic.com>
… diagrams 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 openabdev#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 <noreply@anthropic.com>
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<Mutex<HashMap<u64, oneshot::Sender<Value>>>> 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 <noreply@anthropic.com>
…es (T2.1)
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::<T> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <token> 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:<port>, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… (T4)
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…5.3) 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 <noreply@anthropic.com>
…5.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 <noreply@anthropic.com>
Add acp_tunnel_registry: Option<AcpTunnelRegistry> 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 <noreply@anthropic.com>
Wire the tunnel producer into the connection read loop: - handle_acp_connection mints a per-connection next_req_id (Arc<AtomicU64>) 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 <noreply@anthropic.com>
…5.3, D6-a')
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<Arc<dyn
BrowserTunnel>> (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 <noreply@anthropic.com>
…son (T5.2/D5-a) 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 <workdir>/.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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=<key> uv run scripts/acp-ws-smoke.py ws://<host>/acp Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g-number id
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 <noreply@anthropic.com>
…t session/new 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_<uuid>" 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 <noreply@anthropic.com>
…ervability 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…orphan (M-B1) 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 <noreply@anthropic.com>
start_session_server wrote <workdir>/.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ust 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 <noreply@anthropic.com>
…ort auth Reverses D-20's fail-closed deny-all default. Admission for client-declared `type:acp` servers is now the `/acp` transport auth alone (`OPENAB_ACP_AUTH_KEY`, or loopback + `OPENAB_ACP_ALLOWED_ORIGINS`); every tool a connected server declares is published, unfiltered. The extension already authenticates to reach the tunnel, so a second operator allowlist duplicated an intent the token already carries. This is a redesign, not a field deletion. The allowlist did double duty: the §6.4 security gate AND the source of server names `tools()` iterated to drive discovery. Removing it leaves `tools()` with no names, so discovery is now driven by what is ATTACHED via a re-introduced per-channel enumerator, `AcpMcpTunnel::attached_server_names`. It returns names only — never (name, id) — so every name→id collapse still goes through the single `resolve_by_name`, beside the eviction that makes it unique. This is deliberately NOT the enumerating `servers()` that 74315a6 removed for collapsing same-name entries by two different rules. - config: `acp_servers` and the `AcpServerPolicy` struct are gone. `deny_unknown_fields` turns a leftover `[[mcp.acp_servers]]` block from a silent no-op into a hard parse failure — intended, so a stale allowlist announces itself. - source: `ServerPolicy`/`policy_from_config`/the §6.4 gate deleted; `with_config` -> `new`. `tools()` advertises attached-now UNION already-cached names (the union preserves §6.3: a momentary detach does not shrink the catalog) and publishes every discovered tool unfiltered. `call()` drops the two-step gate; the only refusal left is not-connected, a liveness answer. - gateway: `accept_acp_servers` (the anti-fan-out per-session cap) is untouched — a different mechanism; `declaration_fan_out_is_capped_and_deduplicated` staying green proves the cap still bites. Tests: the four allowlist-gating tests are inverted to the new contract (a connected server's tools ARE published/callable) or removed with the concept they pinned; added coverage that any attached server is callable with no allowlist and that every published tool is advertised unfiltered. The FakeTunnel now partitions `tools/list` per server — the old allowlist masked the double's cross-server contamination that the unfiltered path exposes. Docs (ADR §6.4, setup guide, facade doc, tunnel contract) follow in a separate commit, Part B discipline. Falcon's live config is updated out-of-repo by the operator in lockstep. D-29.
Companion to fe35856, which removed the `[[mcp.acp_servers]]` allowlist in code. Same Part B discipline: correct the fact that changed at paragraph level, state the D-20 -> D-29 reversal in history rather than silently, and leave the already-dated strikethroughs intact. ADR §6.4 rewritten: the `/acp` transport is the gate, not an operator allowlist; admission is transport-auth and a connected server publishes every tool it declares; the not-connected refusal is the only one left. §6.3 rewritten from a two-layer (cache over policy table) model to one layer — `tools()` iterates `attached_server_names` UNION the cached names, with no `fetched ∩ allowed` narrowing. F4 marked implemented-then-removed; F7(a) (log the allowlist refusal) dissolved, F7(b) (policy runtime not wrapping in-process sources) stands. Fixed the scattered current-tense claims that a §6.4 gate refuses unpinned/unlisted tools (:152), that gating is keyed by name (:177 — the name-keying survives in routing, the gating framing does not), and the "allowlisted"/"pinned katashiro" mentions at :518/:599. browser-mcp-agent-setup.md: the config example drops the `[[mcp.acp_servers]]` block (a leftover one now hard-fails to parse); the leftover-bridge warnings reframed from "besides the allowlist" to "besides the transport-authed facade", whose point — a stale entry bypasses the facade's audit — still holds. mcp-over-acp-tunnel-contract.md §6: what appears is what the connected extension publishes, no allowlist to intersect against. doc-drift (R15) clean; no code change. D-29.
Orca's one docs CORRECT on c26732b: §6.4 said a type:acp server "reaches the tunnel only by authenticating to /acp", which reads stronger than the shipped default. Verified against base ADR §2 (acp-server-websocket-base.md:65-78, 207): with OPENAB_ACP_AUTH_KEY unset (the default), /acp binds loopback only and the Origin check rejects unallowlisted BROWSERS, but a request with no Origin — a non-browser local client — is admitted with no credential at all; the endpoint is "unauthenticated by default". Since D-29 made transport admission the ONLY gate, that default posture is load-bearing: any non-browser process on the host can attach a server and publish callable tools. §6.4 now says so directly — the effective default gate is loopback reachability, and OPENAB_ACP_AUTH_KEY is what turns it into actual authentication — rather than leaving it implicit in the base ADR. Not a reopen of the facade/allowlist decision (Orca said so twice, touched no code); a doc-accuracy fix, the same claim-outruns-evidence class this round has chased. doc-drift R15 clean.
|
Important CHANGES REQUESTED What This PR DoesThis PR adds an MCP-over-ACP tunnel so an ACP client can provide MCP tools, including browser-control tools, to an OpenAB agent through the existing WebSocket. It adds tunnel registration, inner MCP lifecycle handling, facade-backed session tokens, discovery, and CI coverage. How It WorksThe gateway accepts Findings
Finding Details🔴 F1: Advertise the standardized capability fieldThe official MCP-over-ACP RFD requires clients to check Requested change: Update the schema dependency or response model so the wire response contains 🔴 F2: Implement both directions of
|
The force-evicting-hung-session warning logged thread_id=%key and session_id raw. Both are credentials: key is the pool key <platform>:<channel_id> carrying acp_<uuid>, and session_id is sess_<uuid> — either resumes the session. R1 redacted the sites it enumerated and this one was outside that list (the enumerated-findings-are-scope-claims trap). Extracted the warn! into warn_force_evicting_hung so the redaction is exercised by a real capture-subscriber test rather than left to inspection. Both fields go through redact_session_ids (which strips acp_/sess_ and keeps the platform half readable). Mutation-verified: reverting either field to raw fails the test on the leaked uuid. Round 6, F6. Not pushed alone — batch-gated with F3/F4/F5 per D-32.
Hung eviction cancels + kills the agent but did NOT revoke its facade token: the token is revoked by the AcpConnection DropGuard, which fires on drop — and a hung connection never drops, because the stuck streaming task still holds an Arc. The token stayed valid, SessionTokens allows multiple tokens per channel, and AcpTunnelSource authorizes by channel alone, so a hung predecessor could invoke whatever tunnel a successor later registered for that channel. Store each connection's exact token lock-free in PoolState.facade_tokens (like cancel_handles/pgids, so eviction never needs the connection lock) and revoke it synchronously on every path that removes a connection: - hung eviction: revoke after apply_hung_eviction succeeds (purge_session_entries deliberately leaves facade_tokens alone so the token is still there to revoke); - install-time supersede: a new connection revokes the token it replaces under the same key, covering the ordering where a successor registers before the hung predecessor is detected; - idle eviction, pool-full suspend, and reset: same revoke, idempotent with the guard on the clean paths where the connection does drop. Two helper-level tests (mutation-proof by construction — they assert the exact revoked-token list): install revokes only the superseded predecessor and keeps the successor's; revoke_facade_token_for_key revokes the exact token and leaves an unrelated session's untouched. The end-to-end call through cleanup_idle shares F6's fixture limit (a real AcpConnection is needed to drive apply_hung_eviction), so the wiring is by inspection. Round 6, F3. Local; batch-gated with F4/F5/F6 per D-32.
Session busy-state is per-WebSocket-connection (`sessions` is built per socket) but the reply-sink registry is process-wide, keyed by channel_id. So two connections resuming the same session both saw busy=false, both started a turn, both inserted a sink under the one channel key, and either turn's completion did an UNCONDITIONAL remove(channel_id) — deleting whichever sink was there, including a successor's live one. The per-connection busy gate cannot serialize turns across connections; this was recorded as the F5-of-round-3 residual. Give the reply sink a `generation` (the connection's `connection_generation`, already stamped at accept) and make the registry ops conditional: - install_reply_sink: a newer connection may take over the channel (a reconnecting client resuming the same session), but an older connection whose prompt is processed late cannot clobber the newer one's sink; - remove_reply_sink_if_owner: a turn's completion removes the sink only if its own `turn_id` (evt_<uuid>, unique per turn) still owns it — so it never deletes a successor turn's live sink. Applied at both the error-path and the normal cleanup remove. handle_reply already turn-fenced READS; this closes the WRITE side. Two unit tests, mutation-verified (an unconditional remove fails "A's completion must not remove B's sink"): a newer connection takes over and an older one cannot clobber it nor remove it; and the same connection's new turn replaces its own sink without the stale turn removing it. Round 6, F4. Local; batch-gated with F3/F5/F6 per D-32.
…ape (F5) `tools()` advertised names exactly as a server published them, but `call()` derived the target server from `split_prefix` — the part before the first `.`. A generic server named `project-tools` publishing a bare `build`, or a server publishing a name whose first segment is not its own (`katashiro` publishing `browser.click`), was discoverable but NOT callable: the bare name was rejected as malformed, and the mismatched prefix routed to a phantom server. The "generic multi-server" claim did not actually hold. Route through the discovery cache instead: `server_publishing` finds the connected server whose fetched `tools/list` contained the tool, so the publisher is looked up from what was discovered rather than parsed out of the tool string. Deterministic on the (already-ambiguous) two-servers-same-bare-name case — the lexicographically-first server wins — so routing never depends on HashMap order. `split_prefix` is kept only as a pre-discovery fallback, so a cold call to a prefixed name still reports "not connected" (the common browser case) rather than "not available". Tests: a bare `build` from `project-tools` and a `browser.click` from `katashiro` are both discoverable and callable, routed to the right tunnel with the original name forwarded (mutation-verified — disabling cache routing fails both). The old malformed-name test inverts: a bare name no connected server published is "not available", not a format error. The FakeTunnel double gains per-server tool lists so a server can publish unprefixed names. Round 6, F5. Local; batch-gated with F3/F4/F6 per D-32.
The gate's default-feature clippy (`cargo clippy --workspace -- -D warnings`) caught it: F3 added `facade_tokens` to PoolState unconditionally, but every reader and writer is `#[cfg(feature = "acp-mcp")]`, so without that feature the field is never read — a `-D warnings` error. My targeted checks used `--features acp-mcp`, which uses the field, so they missed it; the batch gate's default-feature step is exactly the guard for this. Gate `#[cfg(feature = "acp-mcp")]` on the field and its two non-acp-mcp init sites (pool `new()` and the purge-test literal). Default clippy is clean; acp-mcp pool tests still 20/20.
…(F5 D-34) Orca flagged a shadowing interaction after F5: `server_publishing` returned `.min()` of the servers that published a tool, so once content-routing (F5) + no-allowlist (D-29) + keyless-loopback (D-30) let a second local server attach, an impostor whose name sorts earlier could shadow a legit tool of the same literal name — before it took squatting the server NAME and winning rank; after F5, publishing the same tool name was enough. Apply Orca's mitigation: when the tool is `<prefix>.<...>` and one publisher IS `<prefix>`, prefer it — restoring the prefix's authority as a TIEBREAK without reintroducing the old hard split_prefix requirement (a bare or mismatched-prefix name still routes by publisher, so F5 stays intact). Key-gated: moot once OPENAB_ACP_AUTH_KEY is set. A truly bare/unnamespaced name has no prefix to appeal to and still falls to the deterministic lexicographic-min. Test: two servers publish `katashiro.click` (real `katashiro` + earlier-sorting impostor `aaa`); the call routes to `katashiro`. Mutation-verified — dropping the tiebreak routes to the impostor and fails the test. Round 6, F5 shadowing mitigation (D-34). Local; batched with the F2 doc fix.
…igned no (D-34) Brett ruled F2 documentation-only: do not implement inbound `mcp/message` (not the notification half, not the request half). The tunnel contract §4 called the transport "bidirectional" and claimed notifications "travel in both directions," which overstated the code — the gateway has no inbound `mcp/message` dispatch arm, so server-originated requests hit -32601 and inbound notifications are ignored. Correct it to what shipped and why the gap is deliberate. The transport is gateway-initiated (the gateway asks initialize/tools/list/tools/call, the extension answers — the only direction the example exercises). Server-originated requests (sampling/createMessage, elicitation/create, roots/list) and push notifications (tools/list_changed) are not carried inbound, tracking the 2026-07-28 MCP spec which retires exactly that surface: server-initiated requests deprecated (SEP-2577) and redesigned into multi-round-trip requests (SEP-2322 InputRequiredResult/inputRequests); change-notification delivery moving off HTTP push to subscriptions/listen with cache-expiry+refetch; the streamable-HTTP session model removed as the transport goes stateless. The example extension katashiro has a static tool set and never emits tools/list_changed, so there is no consumer to build for. Contract §4 (heading + notification bullet + a deliberate-non-impl note) and the tools/list_changed mention; ADR §6.3 augmented (it already noted list_changed has no consumer) with the spec citation and the server-initiated-request non-impl. Companion-doc discipline. doc-drift R15 clean; no code.
|
Important CHANGES REQUESTED What This PR DoesThis PR adds a gateway-initiated MCP-over-ACP tunnel so an ACP client can expose MCP tools to an OpenAB agent over the existing WebSocket. It connects those session-scoped tools to the OAB MCP Facade through How It WorksThe gateway registers client-declared Findings
Finding Details🟡 F1: Preserve publisher identity for colliding tool namesWhen two declared servers publish the same tool name, Requested change: Deduplicate collisions before publishing, or retain a collision-safe 🟡 F2: Refresh a catalog when its server reconnectsThe cache is keyed by declared name, and Requested change: Record the tunnel id or attachment generation with cached tools and refresh after it changes, while retaining the last successful catalog if the refresh fails. Add a reconnect test with a changed tool list and assert the new id is used. 🟡 F3: Correct the documented tunnel timeout defaultThe protocol contract tells extension implementers that Requested change: Change the contract default to 170 seconds and keep the default/ceiling relationship covered by one shared regression assertion or generated documentation input. 🟡 F4: Use one ACP session tag algorithm everywhere
Requested change: Standardize on hashing the UUID portion and delegate to one helper where dependency direction permits; otherwise pin a cross-module vector that tests both input forms and all implementations. Baseline Check
Validation and External Feedback
Addressing External Reviewer FeedbackPrior consolidated review rounds
ℹ Rechecked: those comments target earlier heads or behavior that was removed during the facade-only redesign. This round is limited to four independently reproducible issues on PR author updates
ℹ Accepted as current scope: this review evaluates that final shape. The duplicate-name routing and reconnect-refresh bugs arise specifically from the generic multi-server discovery model and must be fixed for the advertised capability catalog to be reliable. What's Good (🟢)
5. Three Reasons We Might Not Need This PR
|
…y (F1(b) D-35) The review's F1 asked for a core `mcpCapabilities.acp` field; F1(a) kept it in `_meta` to avoid forking the vendored v1 schema. New evidence: the 2026-07-28 MCP spec formalizes `_meta` for capabilities/metadata AND adds a governed extensions framework keyed by reverse-DNS identifiers (its own reserved keys look like `io.modelcontextprotocol/logLevel`). So a bare `_meta.acp` is an informal convention the framework now supersedes, and the core-field ask is the pre-framework direction. Change the `_meta` key from bare `acp` to `dev.openab/acp` — the reverse-DNS of openab's domain (openab.dev, per the README) plus the capability key, matching the spec's `<reverse-dns-domain>/<key>` form. It stays in `_meta` (a free-form map on the vendored `McpCapabilities`); no `extensions` field is added, so the generated wire types are not forked — F1(a)'s constraint holds. This is alignment to the new extensions convention, not a core-field divergence. - handle_initialize + the conforms mirror both advertise `dev.openab/acp`. - ADR §3.1 (new) documents the reverse-DNS key + the forward-looking framing, citing the 2026-07-28 extensions framework; makes the code comment's "the ADR carries the note" true. - test asserts the reverse-DNS key is present AND the bare `_meta.acp` is gone AND no core `mcpCapabilities.acp` field exists. `dev.openab/acp` is a WIRE identifier — flagged to the orchestrator for sign-off before merge. Round 6, F1(b) (D-35).
…map (F1(b)) Orca's non-blocking precision catch on F1(b): the 2026-07-28 MCP spec has two easily-conflated mechanisms — the `_meta` namespaced-key convention (SEP-1788, what `io.modelcontextprotocol/logLevel` and our `dev.openab/acp` actually are) and the separate typed `extensions` map. The ADR §3.1 and the handle_initialize comment cited "the extensions framework," but what openab follows is the `_meta` convention. The wire string is correct under either reading; only the citation pointed at the neighboring mechanism. Tighten both to say `_meta` namespaced-key convention (SEP-1788) and name it apart from the typed extensions map (which F1(a) deferred). Comment + ADR prose only — the wire key `dev.openab/acp` is unchanged, no logic touched. Applies the reviewer's own suggested wording. Round 6, F1(b) doc precision (Orca).
Round 4 — all six findings answered, head
|
…onnected one (R5 F1, F2) Round 5's F1 and F2 land together because they are the same construct: what the source advertises and what it routes by is now built once, per channel, and that construction is also where a reconnect is noticed. F1 — a colliding tool name kept no routable identity. `tools()` returned a flat `Vec<Tool>` with no server attribution, so two declared servers publishing the same literal name produced two entries under one name. The facade then aliased the second as `<provider>:<tool>` from `source.provider()` — one constant string for the whole source, which cannot tell two of its own servers apart — and `execute_capability` resolved both names to the first matching `Tool` and dispatched under the bare name. Both published names therefore reached the lexicographically-first publisher and the second server's tool was advertised but unreachable. Worse, the surviving order among equal names was a `HashSet` iteration order (nondeterministic per process) while execution resolved to the minimum, so the schema shown for a name could belong to one server while the call was validated against it and dispatched to another. `catalog()` now pairs every advertised name with the `(declared_server, published_tool)` that produced it, and both `tools()` and `call()` go through it, so a name that was advertised is callable and reaches the server whose schema was shown for it. Deterministic by construction: the discovered map is a `BTreeMap`, each server's tools are sorted, and the keeper of a colliding name is chosen by rule. `keeper()` promotes the D-34 namesake preference from a routing tiebreak to a naming rule — a `<prefix>.<...>` name stays with the server called `<prefix>` — and every other publisher is advertised as `<declared_server>.<published_tool>`, routed to its own tunnel and forwarded under the name it published. Keepers claim their names before any rename is assigned, so a rename can never take a name a server actually published; the numeric suffix covers a server that publishes both `x` and its own `<server>.x`. `split_prefix` survives only as the pre-discovery fallback, where the name given is also the name to forward. F2 — a same-name reconnect served its predecessor's catalog. The cache is keyed by declared name precisely so an entry survives a reconnect (ids are minted per connection), but nothing compared that surviving entry against the connection now attached, and no `tools/list_changed` is coming to invalidate it — the tunnel is gateway-initiated (D-34). Each entry now records the `server_id` it was fetched from, and `tools()` refetches when the cached id differs from the one resolved now. The inherited set keeps being served until the refetch lands, so the refresh never shrinks the catalog (§6.3). A fetch that started before the reconnect and lands after it writes the superseded set under the old id, which the next round sees as a mismatch and refetches: self-correcting rather than sticky. Tests. Two servers on one bare name are both advertised, both callable, and reach different tunnels each under its own published name; the tunnel double now stamps its server's name into the advertised schema, so the test can also assert WHOSE schema was shown for the shared name — the half of the defect that is invisible if you only check where the call landed. A rename stepping aside from a literal `<server>.<tool>` a server really published. The namesake keeping its name with the impostor still reachable under its own. And for F2, a reconnect with a new id refreshing the set, plus its converse: an unchanged connection is not refetched on every round. The determinism guard is "assert the expected keeper", not "assert stability across runs": `HashSet` iteration order is fixed per process, so a loop inside one test cannot see the nondeterminism it replaces. ADR §6.2 documents the naming rule and §6.3 the id-stamped refresh.
One session was tagged two ways. `redact_session_ids` (openab-core) and
`redact_id` (openab-gateway's ACP adapter) strip the `acp_`/`sess_` prefix and
hash the uuid; the three `redact_channel` copies hashed the whole `acp_<uuid>`
string. For the review's vector the same session is `#12b9377c` under the first
convention and `#850414fa` under the second — and `#57a483b3` if the `sess_` form
were hashed whole. Correlating a session across logs is the only reason to keep an
identifier in them at all, so two tags defeat the purpose more completely than not
redacting would. It has already cost a debugging session: disjoint channel tags
between the tunnel log and the facade audit log read as a routing bug when the
sets were disjoint only because the two hash inputs disagreed.
All three copies now hash the uuid, so every redactor in the tree agrees on
`#12b9377c`. They also accept the `sess_` form, which previously fell through the
`starts_with("acp_")` guard and reached the log unredacted — a resume credential in
cleartext for any call site that passed a session id.
What made this durable rather than a slip is that each of the three copies pinned
its own cross-crate vector asserting that ITS tag was the shared one, so both
conventions could claim canonical status and a divergence still passed. Where a
crate has a second redactor of its own, its test now compares against that instead
of against a copied literal: openab-core against `redact_session_ids`, and
openab-gateway against `redact_id` (made `pub(crate)` for it, under the `acp`
feature that compiles it). openab-mcp has nothing to compare against, so the
literal stays and says so.
The copies themselves remain — these crates deliberately do not depend on one
another, and a shared crate for six lines is a follow-up, not a mid-PR
architecture change.
The limits table documented `tunnel_timeout_seconds` as defaulting to 180s while `default_tunnel_timeout_seconds()` returns 170. 180 is the agent-side `ACP_PROMPT_IDLE_TIMEOUT_SECS`, i.e. the ceiling the default deliberately keeps a margin under so a hung tunnel call fails as a tunnel timeout rather than as a prompt-idle timeout — which is the distinction the margin exists to preserve. The row now states the real default and what 180 actually is.
Round 5 — all four findings fixed, head
|
What problem does this solve?
The ACP-over-WebSocket base (#1418) ships a 1:1 streaming chat surface at
GET /acp: a browser side-panel extension connects as an ACP client and drives an OpenAB agent. It deliberately stops at chat — no tool calls, no way for the agent's LLM to act on the page it is talking about.This PR delivers the base ADR §6 north-star: let the agent's LLM autonomously operate the user's real, logged-in Chrome (read the DOM, screenshot, navigate, click, type) by exposing the browser as MCP tools and routing them MCP-over-ACP over the
/acpWebSocket the extension already holds. No sandbox VM, no second connection, no per-frontend adapter.It also generalizes that mechanism: any ACP WS client may declare one or more
type:acpMCP servers, and they reach the agent through the OAB MCP Facade (#1448/#1453) as session-aware capability sources (#1454) — not through a bespoke per-session MCP server. Design:acp-server-websocket-reverse-mcp.md— the mechanism, the §6 multi-server generalization, and §7's worked example (browser control, incl. the contract the extension implements).Closes # — None; tracked by the base ADR §6 "Critical path" roadmap, not a separate issue.
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1527521703255605338
At a Glance
Architecture — the extension (a can't-listen MV3 client) is the MCP server, serving tools over its own outbound
/acpWS; OpenAB is the MCP proxy; the agent LLM is the MCP client. Only the extension hop leaves the pod. Downstream, browser capabilities are delivered through the OAB MCP Facade (default); the pre-facadeproxyandbridgetransports remain as explicitOPENAB_BROWSER_MODEopt-outs.flowchart LR EXT["<b>Side-panel MV3 extension</b> = MCP SERVER<br/>(cannot open a listening socket → serves MCP<br/>over the outbound /acp WS it already holds)<br/>declares {type:acp, id, name:'katashiro'}<br/>tools: katashiro.read_dom · screenshot · navigate · click · type"] subgraph POD["OPENAB POD — 'openab run', one process tree"] direction LR GW["<b>openab-gateway</b><br/>/acp WS server<br/>AcpTunnelRegistry<br/>keyed (channel_id, server_id)"] SRC["<b>AcpTunnelSource</b><br/>CapabilitySource<br/>requires_session<br/>allowlist + tool pin"] FAC["<b>OAB MCP Facade</b><br/>127.0.0.1:8848/mcp<br/>search_capabilities<br/>execute_capability"] AGENT["<b>agent CLI</b><br/>Cursor · Kiro · Claude · Codex<br/>LLM = MCP CLIENT"] GW <--> SRC SRC --> FAC FAC ==>|"Authorization: Bearer ${OPENAB_SESSION_TOKEN}<br/>(broker-minted per session, revoked on evict)"| AGENT end EXT <==>|"UPSTREAM — only remote hop<br/>MCP-over-ACP · mcp/message framing<br/>multiplexed with ACP chat on ONE /acp WSS<br/>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,SRC,FAC,AGENT pod;MCP usage sequence (
katashiro.clickexample) — Phase 1 connect + discovery, Phase 2 one autonomous action:sequenceDiagram autonumber participant Tab as Chrome tab participant Ext as extension<br/>MCP SERVER participant GW as gateway /acp participant Src as AcpTunnelSource participant LLM as agent LLM<br/>MCP client Note over Ext,LLM: PHASE 1 — connect & discovery Ext->>GW: initialize · mcpServers=[{type:acp, id, name:"katashiro"}] Ext->>GW: session/new (or resume) GW->>GW: register TunnelHandle at (channel_id, server_id) GW->>Src: broker mints session token → agent MCP config LLM->>Src: search_capabilities (via facade) Src->>GW: tools/list (mcp/message) — once per declared server GW->>Ext: mcp/message → tools/list Ext-->>Src: real tool list → cached per (channel_id, server_id) Src-->>LLM: capabilities: katashiro.* (fetched ∩ allowed) Note over Tab,LLM: PHASE 2 — one autonomous action LLM->>Src: execute_capability katashiro.click(selector) Src->>GW: tools/call (mcp/message, same /acp WS) GW->>Ext: mcp/message → tools/call Ext->>Tab: chrome.scripting / tabs API Tab-->>Ext: DOM mutated / pixels Ext-->>LLM: tool result (JPEG screenshots, frame <= 8 MiB) Note over GW,Ext: only the gateway-to-extension hop leaves the podPrior Art & Industry Research
The problem: give the agent's LLM tools that drive the user's own remote, logged-in browser, where the tool provider is an MV3 extension that cannot open a listening socket.
OpenClaw — browser control is either an OpenClaw-managed, isolated Chrome profile driven by a local control service inside the Gateway (Control UI, managed browser), or the Chrome DevTools MCP server against a locally-reachable Chrome (MCP docs).
openclaw mcp serveis a stdio bridge that keeps a stdio MCP session open and forwards to a local/remote Gateway over WebSocket — but it exposes routed channel conversations as MCP, not a remote browser. In every case the MCP server is colocated with the browser; OpenClaw does not route tools from a remote, can't-listen extension back to a colocated agent.Hermes Agent — built-in browser tools work over accessibility-tree snapshots + stable refs + screenshots, with pluggable backends (Browserbase / Browser Use / Firecrawl cloud, or local Chromium / Camofox) (browser feature).
hermes-computer-useis a pixel-level MCP server (screenshot +xdotoolon an Xvfb display) that any MCP client connects to (repo). Here too the MCP server runs where the browser runs.Takeaway — both projects colocate the browser-tool MCP server with the browser and let the agent be a normal MCP client. Neither tunnels MCP from a remote user's own can't-listen extension back to a colocated agent over the client's existing protocol connection. That inversion is the novel part. (ACP itself, and the general OpenClaw/Hermes ACP comparison, are covered in #1418.)
Proposed Solution
Three layers, behind the existing
acpfeature.1. Protocol gap — agent→client REQUEST direction. The base only did client→agent prompts and agent→client notifications. Browser control needs the agent to ask the client and await a result, so
acp_server's dispatch loop gains a server-initiated request path. Wire types are the generated serde-only v1 types from #1418.2. Upstream hop — MCP-over-ACP tunnel (extension ↔ gateway). A tunnel frame API multiplexes MCP
tools/list/tools/call/ results over the same/acpWS using the officialmcp/messageframing; the client declarestype:acpmcpServers oninitializeand the gateway establishes aTunnelHandleper(channel_id, server_id)— a compound key, so one session can hold several declared servers (re-attach under the same name is last-write-wins). Opened onsession/newandsession/resume, torn down on cleanup. Wire contract:docs/mcp-over-acp-tunnel-contract.md.3. Downstream hop — one
CapabilitySourcebehind the OAB MCP Facade. Every client-declared server is exposed through a single in-processAcpTunnelSource(src/browser_source.rs) registered with the facade:requires_session()— anonymous facade clients neither discover nor can execute these tools. Identity is the facade'sSessionTokens: the broker mints one opaque bearer per agent session, hands it to the agent as${OPENAB_SESSION_TOKEN}(process env, not a config-file secret) and revokes it on evict.tools(ctx)returns the tools of every server that session's client declared;callroutes on the<server>.<tool>prefix back to the right tunnel. No browser-specific branch in the source: admitting another client-side MCP service is config work, not a code change.tools/listis fetched once on attach and cached per(channel_id, server_id); the catalog is served from cache regardless of current attach state, and backend unavailability surfaces as a call error, never a vanishing capability.notifications/tools/list_changedis deliberately not implemented: facade discovery is pull-based (search_capabilitiesre-reads per call), so nothing caches a tool list to invalidate.[[mcp.acp_servers]], default:katashiroonly) gates admitted server names, and each entry carries a deny-alltoolspin. The name allowlist alone grants nothing — the catalog isfetched ∩ allowed, so a client declaring a trusted name still cannot publish extra tools.proxy(per-session loopback HTTP MCP) andbridge(per-pod socket +openab browser-bridgestdio relay) are retained as explicitOPENAB_BROWSER_MODEopt-outs for CLIs or rollouts that need them.Toolset — five DOM-semantic tools (
katashiro.read_dom,katashiro.screenshot,katashiro.navigate,katashiro.click,katashiro.type). The ACP frame cap is raised 1→8 MiB to carry screenshot results (JPEG).Why this approach?
/acpWS it already holds is the only way a can't-listen remote provider can be a full MCP server, and it reuses the one connection.ExtRequest— only tools appear in the LLM's tool list, so only tools let the model autonomously act.computertools —click(selector)/read_domare cheaper, more reliable and model-agnostic.list_changed— keeps a stable catalog across reconnects without fabricating tools.Known limitations — the browser tunnel binds to the ACP session, so the agent must be driven through the extension's ACP session (no Discord↔browser bridge). Under the facade the LLM reaches an action via
search_capabilities→execute_capability, one hop more per turn than a direct tool; a per-provider "expose directly" option is deliberately deferred until interactive latency proves it necessary.Alternatives Considered
ExtRequestper browser action — rejected: not surfaced to the LLM as a tool.computertool (screenshot + pixel coords) — subsumed; DOM-semantic tools are cheaper and model-agnostic.mcp.jsonentries (this PR's own earlier design) — superseded by the facade integration; it would have reinvented the catalog, discovery and policy runtime the facade already provides, and violated the adapter ADR's "one aggregation point".Validation
Rust changes, gated behind
--features acp(openab-gateway/acp+openab-core/acp-mcp):cargo build --features acp— cleancargo clippy --workspace -- -D warningsandcargo clippy --workspace --features unified -- -D warnings— both cleancargo test -p openab-gateway --features acp— green (theacp-gated test target CI runs)<server>.<tool>, allowlist + tool-pin refusal (unpinned tool denied, not "unknown server"), discovery cache narrowing (fetched ∩ allowed, cache can never widen past policy), and two client-declared servers in one sessionscripts/acp-ws-smoke.py(producer section:tools/list/tools/call, fan-out + filtering)127.0.0.1:8848,search_capabilitiesreturns provideropenab-browserwith exactly the five pinnedkatashiro.*capabilities alongside host-levelmcp.jsonproviders, and anonymous probes of the facade correctly see only the two meta-tools (session-bound source hidden without a token)execute_capabilityround-trip driving the real tab — the browser extension, rebuilt on the renamed surface, declares{type:acp, name:"katashiro"}; the gateway registers the tunnel at(channel_id, server_id), the broker mints the session token, and the agent'sexecute_capability katashiro.read_domreaches the user's actual tab and returns its DOM. The facade's audit trail brackets the dispatch:Review Contract
Goal
Let a colocated agent's LLM autonomously operate the user's real remote browser via DOM-semantic MCP tools tunnelled MCP-over-ACP over the existing
/acpWebSocket, and do it through the OAB MCP Facade as a session-awareCapabilitySourcerather than a second agent-facing MCP server. Concretely: the agent→client request direction; themcp/messagetunnel keyed by(channel_id, server_id);AcpTunnelSourcewith multi-server fan-out, per-server discovery cache and an operator allowlist with deny-all tool pinning; broker-minted per-session tokens for identity.Non-goals
proxy/bridgetransports (kept asOPENAB_BROWSER_MODEopt-outs; see Follow-ups).notifications/tools/list_changed— deliberately dropped, not deferred (pull-based facade discovery has no consumer for it).Accepted Residual Risks
127.0.0.1and trusts the host boundary; the per-session bearer rides the agent's process env. A hostile in-pod process shares the agent subprocess's trust boundary already.bridgemode resolves its channel by process ancestry — correct for the normal spawn tree; a relay started outside it fails closed.Acceptance Criteria
cargo build --features acp,cargo clippy --workspace [--features unified] -- -D warnings, andcargo test -p openab-gateway --features acpall green.search_capabilitieslists exactly the pinned capabilities for a session-bound client, and anonymous clients see none of them.Follow-ups
bridgemode once Facade mode has soaked. Beyond the adapter ADR's "one aggregation point", live testing surfaced a concrete hazard: each transport writer only adds its ownmcp.jsonentry and never removes the other's, so an agent that has run in more than one mode loads both servers and exposes the same tools twice — once through the facade (policy + audit) and once straight through the old transport (neither). The model picks the direct one and the call leaves no audit trail at all while working perfectly; auditing looks dead with nothing indicating why. Documented inbrowser-mcp-agent-setup.mdas an operational warning, but the durable fix is one transport (and, until then, cleanup-on-mode-switch).mcpServersand explicitly not by writing CLI config files, while the as-built writes a staticopenabentry because Cursor ignores ACP-passedmcpServers(browser ADR D2 / zed#50924). Owner of the facade contract to decide.initializedeclaration so the catalog is known without a first tunnel round-trip.