@@ -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
668673impl 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) ]
708720async 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
776800async 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
0 commit comments