Skip to content

Commit 5dc45de

Browse files
brettchienclaude
andcommitted
feat(acp): record declared server name, establish all declared tunnels, LWW on re-attach
Tunnel-layer plumbing for §6 F1' (generalising the capability source to N client-declared servers). Behaviour-neutral for today's single-browser client: one declared server still yields exactly one tunnel. A declaration is {type:"acp", id, name} and the two fields have different lifetimes — the reference client mints `id` as a fresh crypto.randomUUID() per connection while `name` ("browser") is stable. The registry is keyed by `id`, but a tool name carries the `name` (browser.click) and the §6.4 trust gate is keyed by it too, so the name has to survive registration to be routable. - TunnelHandle records the declared `server_name` and exposes it. - establish_and_register_tunnel takes the declared name and resolves a re-declared name last-attach-wins: the new tunnel evicts stale same-name entries on the channel. Because a reconnect mints a new id, the dead tunnel would otherwise linger beside the live one, and answering "ambiguous, pass a server_id" there would wedge the client out of its own tools on every reconnect. The eviction is also what bounds registry growth. - spawn_browser_tunnel -> spawn_acp_tunnels now establishes EVERY declared server. The old first-only limit existed because the registry was keyed by channel_id alone, where a second server overwrote the first and orphaned its tunnel; the compound key removed that collision. - AcpMcpTunnel gains servers(channel_id) -> Vec<(name, id)>, implemented by RootBrowserTunnel over the registry. This is what lets a capability source resolve a tool prefix back to a tunnel; matching a prefix against the registry key alone can never work, since the key is a UUID the tool name never contains. Default impl is empty so test doubles are unaffected. The source-side consumer (AcpTunnelSource routing + the §6.4 trust gate) is the next step; nothing reads servers() yet. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0cc6dd5 commit 5dc45de

3 files changed

Lines changed: 166 additions & 40 deletions

File tree

crates/openab-core/src/mcp_proxy.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ pub trait AcpMcpTunnel: Send + Sync {
4444
method: &str,
4545
params: Option<Value>,
4646
) -> Result<Value, String>;
47+
48+
/// The `type:acp` servers currently registered for `channel_id`, as `(declared_name,
49+
/// server_id)` pairs.
50+
///
51+
/// Both halves are needed and they are *not* interchangeable (ADR §6.1): the registry is keyed
52+
/// by the client-minted `server_id`, which the reference client mints as a fresh UUID **per
53+
/// connection**, while a tool name carries the stable declared **name** (`browser.click`) and
54+
/// the §6.4 trust gate is keyed by that name too. Enumerating both is what lets a capability
55+
/// source resolve a tool prefix back to a tunnel; matching a prefix against the registry key
56+
/// alone can never work.
57+
///
58+
/// Sync because implementations just read an in-memory registry. The default is empty, so
59+
/// implementations that track no declarations (test doubles, single-target bridges) simply
60+
/// advertise nothing.
61+
fn servers(&self, _channel_id: &str) -> Vec<(String, String)> {
62+
Vec::new()
63+
}
4764
}
4865

4966
/// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM-

crates/openab-gateway/src/adapters/acp_server.rs

Lines changed: 137 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -663,9 +663,20 @@ pub struct TunnelHandle {
663663
pending: Arc<tokio::sync::Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
664664
next_id: Arc<AtomicU64>,
665665
connection_id: String,
666+
/// The `name` the client declared for this server (e.g. `"browser"`). Stable across
667+
/// reconnects, unlike the `id` the registry keys by — the reference client mints that as a
668+
/// fresh UUID per connection. Tool prefixes (`browser.click`) and the §6.4 trust allowlist are
669+
/// both keyed by this name, so it must survive registration to be routable (ADR §6.1).
670+
server_name: String,
666671
}
667672

668673
impl TunnelHandle {
674+
/// The client-declared server name for this tunnel (see the field docs for why the declared
675+
/// name and the registry key are deliberately different things).
676+
pub fn server_name(&self) -> &str {
677+
&self.server_name
678+
}
679+
669680
/// Tunnel an inner MCP request (`tools/list`, `tools/call`, …) to the extension over this
670681
/// connection and return the inner MCP result payload.
671682
pub async fn mcp_message(
@@ -705,43 +716,61 @@ impl TunnelHandle {
705716
/// the client's response, which only that same read loop can deliver — awaiting it inline
706717
/// would deadlock.
707718
#[allow(dead_code)]
719+
#[allow(clippy::too_many_arguments)]
708720
async fn establish_and_register_tunnel(
709721
out_tx: mpsc::UnboundedSender<String>,
710722
pending: Arc<tokio::sync::Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
711723
next_id: Arc<AtomicU64>,
712724
acp_id: String,
725+
acp_name: String,
713726
channel_id: String,
714727
registry: AcpTunnelRegistry,
715728
timeout_secs: u64,
716729
) -> Result<(), String> {
717730
// Observability: reaching here means the client DID declare a "type":"acp" server, so this
718731
// line in the log answers "did the browser extension advertise itself?" for a live session.
719-
info!(acp_id = %acp_id, channel_id = %channel_id, "ACP: opening MCP-over-ACP browser tunnel");
732+
info!(acp_id = %acp_id, acp_name = %acp_name, channel_id = %channel_id, "ACP: opening MCP-over-ACP tunnel");
720733
let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?;
721734
let handle = TunnelHandle {
722735
out_tx,
723736
pending,
724737
next_id,
725738
connection_id,
739+
server_name: acp_name.clone(),
740+
};
741+
let evicted = {
742+
let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner());
743+
// Last-attach-wins (ADR §6.1). The client mints a fresh `id` on every connection, so a
744+
// reconnect would otherwise leave the dead tunnel registered beside the live one under
745+
// the same declared name. Answering "ambiguous — pass a server_id" there would wedge the
746+
// client out of its own tools on every reconnect, so the newest attach evicts its stale
747+
// same-name predecessors instead — which is also what bounds registry growth.
748+
let before = reg.len();
749+
reg.retain(|(c, id), h| !(c == &channel_id && h.server_name == acp_name && id != &acp_id));
750+
let evicted = before - reg.len();
751+
reg.insert((channel_id.clone(), acp_id.clone()), handle);
752+
evicted
726753
};
727-
registry
728-
.lock()
729-
.unwrap_or_else(|e| e.into_inner())
730-
.insert((channel_id.clone(), acp_id.clone()), handle);
731-
info!(channel_id = %channel_id, server_id = %acp_id, "ACP: browser tunnel registered — extension attached");
754+
if evicted > 0 {
755+
info!(
756+
channel_id = %channel_id, server_name = %acp_name, evicted,
757+
"ACP: last-attach-wins — evicted stale same-name tunnel(s)"
758+
);
759+
}
760+
info!(channel_id = %channel_id, server_id = %acp_id, server_name = %acp_name, "ACP: tunnel registered — client MCP server attached");
732761
Ok(())
733762
}
734763

735-
/// Open + register the browser tunnel for a session's declared `type:acp` servers.
764+
/// Open + register a tunnel for **every** `type:acp` server the session's client declared.
736765
///
737-
/// Exactly ONE tunnel per session is supported: the core proxy resolves a browser by
738-
/// `channel_id`, so registering a second server under the same `channel_id` would overwrite
739-
/// the first in the registry and orphan its already-opened tunnel. We therefore establish only
740-
/// the first declared server and warn if the client sent more. Spawned (not awaited inline)
741-
/// because `establish_and_register_tunnel` awaits the client's `mcp/connect` response, which
742-
/// only the read loop delivers — awaiting inline would deadlock.
766+
/// The old "first declared server only" limit came from the registry being keyed by `channel_id`
767+
/// alone, where a second server would overwrite the first and orphan its open tunnel. The compound
768+
/// `(channel_id, server_id)` key removed that collision, so all declared servers are established
769+
/// now; a re-declared *name* is resolved last-attach-wins inside `establish_and_register_tunnel`
770+
/// (ADR §6.1). Spawned (not awaited inline) because that function awaits the client's
771+
/// `mcp/connect` response, which only the read loop delivers — awaiting inline would deadlock.
743772
#[allow(clippy::too_many_arguments)]
744-
fn spawn_browser_tunnel(
773+
fn spawn_acp_tunnels(
745774
servers: Vec<AcpMcpServer>,
746775
channel_id: String,
747776
registry: AcpTunnelRegistry,
@@ -750,27 +779,22 @@ fn spawn_browser_tunnel(
750779
next_id: &Arc<AtomicU64>,
751780
prompt_tasks: &mut Vec<tokio::task::JoinHandle<()>>,
752781
) {
753-
if servers.len() > 1 {
754-
warn!(
755-
channel_id = %channel_id,
756-
count = servers.len(),
757-
"ACP: multiple type:acp servers declared; only one browser tunnel per session is supported — using the first"
758-
);
782+
for srv in servers {
783+
let out_tx = out_tx.clone();
784+
let pending = pending.clone();
785+
let next_id = next_id.clone();
786+
let registry = registry.clone();
787+
let channel_id = channel_id.clone();
788+
prompt_tasks.push(tokio::spawn(async move {
789+
if let Err(e) = establish_and_register_tunnel(
790+
out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30,
791+
)
792+
.await
793+
{
794+
warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel");
795+
}
796+
}));
759797
}
760-
let Some(srv) = servers.into_iter().next() else {
761-
return;
762-
};
763-
let out_tx = out_tx.clone();
764-
let pending = pending.clone();
765-
let next_id = next_id.clone();
766-
prompt_tasks.push(tokio::spawn(async move {
767-
if let Err(e) =
768-
establish_and_register_tunnel(out_tx, pending, next_id, srv.id, channel_id, registry, 30)
769-
.await
770-
{
771-
warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel");
772-
}
773-
}));
774798
}
775799

776800
async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
@@ -961,7 +985,7 @@ async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
961985
// inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response
962986
// only THIS read loop delivers — awaiting inline would deadlock.
963987
if let Some(registry) = state.acp_tunnel_registry.clone() {
964-
spawn_browser_tunnel(
988+
spawn_acp_tunnels(
965989
acp_mcp_servers,
966990
channel_id.clone(),
967991
registry,
@@ -1003,7 +1027,7 @@ async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
10031027
.and_then(|v| v.as_str())
10041028
.and_then(derive_channel_id)
10051029
{
1006-
spawn_browser_tunnel(
1030+
spawn_acp_tunnels(
10071031
parse_acp_mcp_servers(req.params.as_ref()),
10081032
channel_id,
10091033
registry,
@@ -2189,6 +2213,7 @@ mod acp_requests {
21892213
pending: pending.clone(),
21902214
next_id,
21912215
connection_id: "conn-9".into(),
2216+
server_name: "browser".into(),
21922217
};
21932218

21942219
let pending2 = pending.clone();
@@ -2231,6 +2256,7 @@ mod acp_requests {
22312256
pending,
22322257
next_id,
22332258
"srv-1".into(),
2259+
"browser".into(),
22342260
"acp_abc".into(),
22352261
registry.clone(),
22362262
5,
@@ -2239,13 +2265,84 @@ mod acp_requests {
22392265
.unwrap();
22402266
ext.await.unwrap();
22412267

2268+
let reg = registry.lock().unwrap();
2269+
let handle = reg.get(&("acp_abc".to_string(), "srv-1".to_string()));
22422270
assert!(
2243-
registry
2244-
.lock()
2245-
.unwrap()
2246-
.contains_key(&("acp_abc".to_string(), "srv-1".to_string())),
2271+
handle.is_some(),
22472272
"a TunnelHandle must be registered under (channel_id, server_id)"
22482273
);
2274+
assert_eq!(
2275+
handle.unwrap().server_name(),
2276+
"browser",
2277+
"the declared name must survive registration — tool prefixes and the trust allowlist \
2278+
match on it, not on the per-connection id"
2279+
);
2280+
}
2281+
2282+
/// Drive one `establish_and_register_tunnel` against a mock client that answers `mcp/connect`.
2283+
async fn attach(registry: &super::AcpTunnelRegistry, server_id: &str, name: &str) {
2284+
let pending = new_pending();
2285+
let next_id = Arc::new(AtomicU64::new(1));
2286+
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
2287+
let pending2 = pending.clone();
2288+
let ext = tokio::spawn(async move {
2289+
let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap();
2290+
route_client_response(
2291+
&pending2,
2292+
&json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-1"}}),
2293+
)
2294+
.await;
2295+
});
2296+
super::establish_and_register_tunnel(
2297+
out_tx,
2298+
pending,
2299+
next_id,
2300+
server_id.into(),
2301+
name.into(),
2302+
"acp_abc".into(),
2303+
registry.clone(),
2304+
5,
2305+
)
2306+
.await
2307+
.unwrap();
2308+
ext.await.unwrap();
2309+
}
2310+
2311+
/// Last-attach-wins (ADR §6.1): the client mints a fresh `id` per connection, so a reconnect
2312+
/// re-declares the same `name` under a new id. The new tunnel must replace the stale one
2313+
/// rather than coexist with it — coexistence is what would make routing ambiguous and wedge
2314+
/// the client out of its own tools on every reconnect.
2315+
#[tokio::test]
2316+
async fn reattaching_same_name_evicts_the_stale_tunnel() {
2317+
let registry = super::new_tunnel_registry();
2318+
attach(&registry, "uuid-old", "browser").await;
2319+
attach(&registry, "uuid-new", "browser").await;
2320+
2321+
let reg = registry.lock().unwrap();
2322+
assert_eq!(
2323+
reg.len(),
2324+
1,
2325+
"the reconnect must evict the stale same-name tunnel, not accumulate beside it"
2326+
);
2327+
assert!(
2328+
reg.contains_key(&("acp_abc".to_string(), "uuid-new".to_string())),
2329+
"the most recently attached tunnel is the one that survives"
2330+
);
2331+
}
2332+
2333+
/// A different declared name on the same channel is a genuinely different server and must
2334+
/// coexist — that is the whole point of the compound key (§6.1/§6.2 fan-out).
2335+
#[tokio::test]
2336+
async fn different_names_on_one_channel_coexist() {
2337+
let registry = super::new_tunnel_registry();
2338+
attach(&registry, "uuid-b", "browser").await;
2339+
attach(&registry, "uuid-o", "other").await;
2340+
2341+
assert_eq!(
2342+
registry.lock().unwrap().len(),
2343+
2,
2344+
"distinct declared names are distinct servers and must both stay registered"
2345+
);
22492346
}
22502347
}
22512348

src/browser_tunnel.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,16 @@ impl AcpMcpTunnel for RootBrowserTunnel {
5555
None => Err(format!("no browser attached to session {channel_id}")),
5656
}
5757
}
58+
59+
/// Enumerate this channel's registered tunnels as `(declared_name, server_id)` (ADR §6.1).
60+
/// The name is what a tool prefix and the §6.4 allowlist match on; the id is what the registry
61+
/// is keyed by. Same-name duplicates cannot appear here — `establish_and_register_tunnel`
62+
/// evicts the stale entry on attach (last-attach-wins).
63+
fn servers(&self, channel_id: &str) -> Vec<(String, String)> {
64+
let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner());
65+
reg.iter()
66+
.filter(|((c, _), _)| c == channel_id)
67+
.map(|((_, id), h)| (h.server_name().to_string(), id.clone()))
68+
.collect()
69+
}
5870
}

0 commit comments

Comments
 (0)