Skip to main content

bamboo_broker/
mcp.rs

1//! MCP-over-broker proxy (P2): a remote/deployed worker invokes host-bound MCP
2//! servers (e.g. nova — needs the screen/local creds) that physically run on the
3//! orchestrator, by forwarding the tool calls over the broker.
4//!
5//! - Worker side: [`McpProxyExecutor`] advertises the orchestrator's proxiable
6//!   MCP tools (fetched as a manifest) and forwards each call. It uses its own
7//!   broker sub-connection (`<worker>#mcp`) so proxy replies don't collide with
8//!   the worker's main ask mailbox.
9//! - Orchestrator side: [`serve_mcp_proxy`] answers `McpRequest`s from a backend
10//!   [`ToolExecutor`] (the real `McpServerManager`).
11
12use std::collections::{HashMap, HashSet};
13use std::future::Future;
14use std::sync::{Arc, RwLock};
15use std::time::Duration;
16
17use async_trait::async_trait;
18use bamboo_agent_core::tools::{
19    FunctionCall, ToolCall, ToolError, ToolExecutionContext, ToolExecutor, ToolResult, ToolSchema,
20};
21use bamboo_subagent::{AgentRef, InboxKind, InboxMessage, MsgId};
22use chrono::Utc;
23use serde::{Deserialize, Serialize};
24use tokio::sync::Mutex;
25use tokio_util::sync::CancellationToken;
26
27use crate::client::BrokerClient;
28use crate::error::{BrokerError, BrokerResult};
29use crate::mux::MultiplexedClient;
30
31// --- supervised-reconnect tuning (issue #47) ----------------------------------
32
33/// Initial backoff for the orchestrator-side MCP proxy supervisor. The proxy
34/// connection is long-lived, so this only gates *restarts* after a drop.
35const PROXY_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(500);
36/// Cap for the orchestrator proxy reconnect backoff.
37const PROXY_RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(30);
38/// A serve run that lasts at least this long counts as "healthy": the next
39/// restart resets the backoff to the floor instead of continuing to grow it,
40/// so a single blip doesn't leave a large lingering backoff.
41const PROXY_BACKOFF_RESET_AFTER: Duration = Duration::from_secs(10);
42
43/// Initial backoff for the worker-side lazy reconnect.
44const WORKER_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
45/// Cap for the worker-side lazy reconnect backoff.
46const WORKER_RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(2);
47/// Maximum reconnect attempts per call before surfacing a transient error. A
48/// later call can try again; the executor is never permanently disabled.
49const WORKER_RECONNECT_MAX_ATTEMPTS: u32 = 5;
50
51/// Body of an `McpRequest` (worker → orchestrator).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(tag = "op", rename_all = "snake_case")]
54pub enum McpRequest {
55    /// Ask which (host-bound) MCP tools the orchestrator can proxy.
56    Manifest,
57    /// Invoke a proxiable tool with the LLM-provided JSON arguments string.
58    Call { tool: String, arguments: String },
59}
60
61/// Body of an `McpReply` (orchestrator → worker).
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct McpReply {
64    /// Manifest response: the proxiable tool schemas.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub manifest: Option<Vec<ToolSchema>>,
67    /// Call response: the tool result.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub result: Option<ProxiedResult>,
70    /// Set when the request could not be served.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub error: Option<String>,
73}
74
75/// A proxied tool result (the wire-safe subset of `ToolResult`).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ProxiedResult {
78    pub success: bool,
79    pub result: String,
80}
81
82// ---- role allowlist (issue #54) ----------------------------------------------
83
84/// Per-role allowlist that scopes which host-bound MCP tools a worker may see
85/// and call through the proxy (principle of least privilege).
86///
87/// The orchestrator's MCP host exposes powerful, host-bound tools (screen
88/// capture, local credentials, …). Without scoping, *every* worker — regardless
89/// of role — gets the orchestrator's entire MCP tool set in both its advertised
90/// manifest and as a callable surface, so a hallucinating worker could invoke a
91/// tool its role has no business touching. This policy lets the deployer restrict
92/// each role to an explicit set of tools.
93///
94/// Resolution for a requesting worker's role (`AgentRef.role`):
95/// - **Role with an explicit allowlist** → only the intersection of that
96///   allowlist with the backend's tools is exposed; a `Call` for a tool not on
97///   the allowlist is rejected (defense in depth — the manifest already hides it,
98///   but a worker could still try to call it directly).
99/// - **Role with no entry, or a request with no role** → all tools are exposed
100///   (backward compatible). An empty/default allowlist therefore restricts
101///   nothing, preserving the behavior of existing unrestricted workers.
102///
103/// The default is **allow-all for unlisted roles** (not deny-by-default) so that
104/// dropping this feature in does not silently strip tools from already-deployed
105/// workers; the issue (#54) asks for restricted roles to be filtered while
106/// default/unrestricted roles keep all tools. Restrictions are therefore opt-in
107/// and explicit per role.
108///
109/// # Trust boundary: the role is SELF-ASSERTED, not authenticated
110///
111/// `serve_mcp_proxy` resolves the requesting role from `msg.from.role` — the
112/// `AgentRef` the connecting worker chose to Hello/subscribe with — not from any
113/// identity independently verified against the broker's auth (a shared bearer
114/// token authenticates the *connection*, not a claimed *role* on it). A worker
115/// that connects with a bearer token valid for the broker can claim ANY role
116/// string in its `AgentRef`, including one with a wider (or no) allowlist entry,
117/// and the proxy has no way to tell it apart from a legitimately-deployed worker
118/// of that role.
119///
120/// Concretely, this allowlist is:
121/// - **Adequate** against a confused/hallucinating worker running its assigned
122///   role honestly (the common case this issue targets — least-privilege
123///   scoping so an LLM's tool-call mistake can't reach `nova_screenshot` from a
124///   `researcher` role).
125/// - **Not** a security boundary against a worker that is compromised or
126///   deliberately malicious — such a worker can simply assert a role that is
127///   unrestricted (or has a wider allowlist) and bypass this policy entirely.
128///
129/// Closing that gap needs authenticating the role itself (e.g. per-role broker
130/// credentials, or signing the `AgentRef` at provisioning time) — out of scope
131/// for this issue; deployers who need that guarantee should not rely on this
132/// allowlist as their only control on a genuinely untrusted worker.
133#[derive(Debug, Clone, Default)]
134pub struct RoleToolAllowlist {
135    /// role → set of tool names that role is allowed to proxy. A role absent from
136    /// this map is unrestricted (sees/can call all backend tools).
137    by_role: HashMap<String, HashSet<String>>,
138}
139
140impl RoleToolAllowlist {
141    /// An empty allowlist: every role is unrestricted (back-compat default).
142    pub fn unrestricted() -> Self {
143        Self::default()
144    }
145
146    /// Build from `(role, allowed_tool_names)` entries. A role mapped to an empty
147    /// set is still "restricted" — it gets *no* tools (an explicit lockout),
148    /// distinct from a role that is simply absent (unrestricted).
149    pub fn from_entries<R, T, I>(entries: I) -> Self
150    where
151        R: Into<String>,
152        T: Into<String>,
153        I: IntoIterator<Item = (R, Vec<T>)>,
154    {
155        let by_role = entries
156            .into_iter()
157            .map(|(role, tools)| (role.into(), tools.into_iter().map(Into::into).collect()))
158            .collect();
159        Self { by_role }
160    }
161
162    /// Add/replace one role's allowlist (builder-style).
163    pub fn with_role(
164        mut self,
165        role: impl Into<String>,
166        tools: impl IntoIterator<Item = impl Into<String>>,
167    ) -> Self {
168        self.by_role
169            .insert(role.into(), tools.into_iter().map(Into::into).collect());
170        self
171    }
172
173    /// Build from config-sourced `(role, tools)` entries (issue #54), logging
174    /// load-time warnings for likely misconfiguration instead of silently
175    /// failing open. Exact-string matching means a typo'd role or tool name
176    /// has NO other signal: a misspelled role just never matches (looks
177    /// unrestricted to the operator, since the intended role falls through to
178    /// "no entry"), and a misspelled tool name silently grants nothing for the
179    /// tool the deployer actually meant to allow. This is the one point where
180    /// that can be caught.
181    ///
182    /// There is no fixed registry of valid ROLE names in this codebase (roles
183    /// are free-form profile ids assigned at deploy/spawn time), so role
184    /// validation here is structural only:
185    /// - A blank/whitespace-only role is dropped (warned) — it can never match
186    ///   a real `AgentRef.role`.
187    /// - A duplicate role entry: the later one wins (warned) — config authors
188    ///   should not rely on ordering.
189    ///
190    /// TOOL names, by contrast, CAN be checked against a real source of truth:
191    /// pass the backend's currently-registered tool names as `known_tools`
192    /// (empty ⇒ skip this check, e.g. when the backend's tool set is not yet
193    /// known at config-load time). A tool name not in `known_tools` is still
194    /// kept (still enforced — the backend can register tools after this
195    /// policy is built, so absence isn't proof of a typo) but is warned as a
196    /// likely typo.
197    pub fn from_config<R, T, I>(entries: I, known_tools: &HashSet<String>) -> Self
198    where
199        R: Into<String>,
200        T: Into<String>,
201        I: IntoIterator<Item = (R, Vec<T>)>,
202    {
203        let mut by_role: HashMap<String, HashSet<String>> = HashMap::new();
204        for (role, tools) in entries {
205            let role: String = role.into().trim().to_string();
206            if role.is_empty() {
207                tracing::warn!(
208                    "mcp role allowlist config: skipping an entry with a blank role name \
209                     (it could never match a real worker's role)"
210                );
211                continue;
212            }
213            if by_role.contains_key(&role) {
214                tracing::warn!(
215                    role = %role,
216                    "mcp role allowlist config: duplicate entry for this role; \
217                     the later entry replaces the earlier one"
218                );
219            }
220            let tool_set: HashSet<String> = tools
221                .into_iter()
222                .map(Into::into)
223                .filter_map(|t| {
224                    let t = t.trim().to_string();
225                    if t.is_empty() {
226                        tracing::warn!(
227                            role = %role,
228                            "mcp role allowlist config: skipping a blank tool name"
229                        );
230                        None
231                    } else {
232                        Some(t)
233                    }
234                })
235                .collect();
236            if !known_tools.is_empty() {
237                for tool in &tool_set {
238                    if !known_tools.contains(tool) {
239                        tracing::warn!(
240                            role = %role,
241                            tool = %tool,
242                            "mcp role allowlist config: tool name is not in the orchestrator's \
243                             current MCP tool set (check for a typo) — the entry is still \
244                             enforced, so a mistyped name silently grants nothing for it"
245                        );
246                    }
247                }
248            }
249            by_role.insert(role, tool_set);
250        }
251        Self { by_role }
252    }
253
254    /// Whether `role` is restricted (has an explicit allowlist entry). A `None`
255    /// role, or a role with no entry, is unrestricted.
256    fn is_restricted(&self, role: Option<&str>) -> bool {
257        role.is_some_and(|r| self.by_role.contains_key(r))
258    }
259
260    /// Whether `role` is allowed to use `tool`. Unrestricted roles allow any tool;
261    /// a restricted role allows only the tools on its set.
262    fn allows(&self, role: Option<&str>, tool: &str) -> bool {
263        match role.and_then(|r| self.by_role.get(r)) {
264            Some(allowed) => allowed.contains(tool),
265            None => true, // unrestricted (no entry / no role)
266        }
267    }
268
269    /// Filter a full tool manifest down to what `role` may see. Unrestricted
270    /// roles get the manifest unchanged; restricted roles get the intersection.
271    fn filter_manifest(&self, role: Option<&str>, mut tools: Vec<ToolSchema>) -> Vec<ToolSchema> {
272        if let Some(allowed) = role.and_then(|r| self.by_role.get(r)) {
273            tools.retain(|t| allowed.contains(&t.function.name));
274        }
275        tools
276    }
277}
278
279// ---- orchestrator side --------------------------------------------------------
280
281/// Run the orchestrator-side MCP proxy: connect as `me`, subscribe, and answer
282/// each `McpRequest` from `backend` (the real MCP `ToolExecutor`). Serves until
283/// the connection drops.
284pub async fn serve_mcp_proxy(
285    endpoint: &str,
286    me: AgentRef,
287    token: &str,
288    backend: Arc<dyn ToolExecutor>,
289    allowlist: Arc<RoleToolAllowlist>,
290) -> BrokerResult<()> {
291    let mut client = BrokerClient::connect(endpoint, me.clone(), token).await?;
292    client.subscribe().await?;
293
294    // Run each request's (potentially slow) backend call CONCURRENTLY in a spawned
295    // task, and route the finished reply back to this loop — the single client
296    // owner — which delivers + acks it. So N parallel McpRequests overlap their
297    // backend work instead of the old serial `handle(msg).await` per message. The
298    // worker side multiplexes replies by correlation_id, so out-of-order completion
299    // is fine. (Spawns are unbounded but bounded in practice by the LLM's parallel
300    // tool-call batch; a Semaphore cap is a future option if a backend needs one.)
301    // #144.
302    //
303    // KEEP-ALIVE: this original `reply_tx` is intentionally retained in scope for
304    // the whole loop (each spawn clones it). It guarantees `reply_rx.recv()` never
305    // returns `None` while looping, which is what lets the reply arm's
306    // `Some(..) = reply_rx.recv()` always eventually match — do NOT drop it (e.g.
307    // by only cloning into the spawn) or the reply arm goes permanently dead.
308    let (reply_tx, mut reply_rx) =
309        tokio::sync::mpsc::unbounded_channel::<(MsgId, String, McpReply)>();
310    loop {
311        tokio::select! {
312            // Deliver a completed reply + ack its request (cheap; serialized
313            // through the owner, never blocks a backend call).
314            Some((corr, reply_to, reply_body)) = reply_rx.recv() => {
315                let reply = InboxMessage {
316                    id: MsgId::new(),
317                    from: me.clone(),
318                    kind: InboxKind::McpReply,
319                    body: serde_json::to_value(reply_body).unwrap_or_default(),
320                    created_at: Utc::now(),
321                    correlation_id: Some(corr.clone()),
322                };
323                client.deliver(&reply_to, reply).await?;
324                client.ack(corr).await?;
325            }
326            msg = client.next_message() => {
327                let Some(msg) = msg else { break }; // connection closed
328                if msg.kind != InboxKind::McpRequest {
329                    let _ = client.ack(msg.id).await;
330                    continue;
331                }
332                let backend = Arc::clone(&backend);
333                let allowlist = Arc::clone(&allowlist);
334                let reply_tx = reply_tx.clone();
335                let corr = msg.id.clone();
336                let reply_to = msg.from.session_id.clone();
337                tokio::spawn(async move {
338                    let reply_body = handle_mcp_request(backend.as_ref(), &allowlist, msg).await;
339                    // Receiver gone == loop exited (connection dropped) -> drop.
340                    let _ = reply_tx.send((corr, reply_to, reply_body));
341                });
342            }
343        }
344    }
345    Ok(())
346}
347
348/// Supervised orchestrator-side MCP proxy (issue #47): run [`serve_mcp_proxy`]
349/// in a loop, restarting it with bounded exponential backoff whenever the broker
350/// connection drops, and stop cleanly when `shutdown` is cancelled.
351///
352/// A healthy, long-lived connection behaves identically to the bare
353/// [`serve_mcp_proxy`] — this only adds resilience to transient WebSocket drops.
354///
355/// - **Backoff**: starts at [`PROXY_RECONNECT_INITIAL_BACKOFF`], doubles on each
356///   quick restart, and is capped at [`PROXY_RECONNECT_MAX_BACKOFF`]. Reset to
357///   the floor after a run that lasted ≥ [`PROXY_BACKOFF_RESET_AFTER`] (a healthy
358///   connection), so a brief blip doesn't leave a large backoff lingering.
359/// - **Shutdown**: the in-flight serve is raced against `shutdown.cancelled()` so
360///   an intended stop interrupts it promptly; the backoff sleep between restarts
361///   is also raced against shutdown. The loop never restarts once shutdown is
362///   requested, so there is no leaked task / infinite restart after a stop.
363pub async fn serve_mcp_proxy_supervised(
364    endpoint: &str,
365    me: AgentRef,
366    token: &str,
367    backend: Arc<dyn ToolExecutor>,
368    allowlist: Arc<RoleToolAllowlist>,
369    shutdown: CancellationToken,
370) {
371    supervise_reconnect(
372        || {
373            serve_mcp_proxy(
374                endpoint,
375                me.clone(),
376                token,
377                backend.clone(),
378                allowlist.clone(),
379            )
380        },
381        shutdown,
382        PROXY_RECONNECT_INITIAL_BACKOFF,
383        PROXY_RECONNECT_MAX_BACKOFF,
384        PROXY_BACKOFF_RESET_AFTER,
385    )
386    .await
387}
388
389/// Generic reconnect supervisor: call `serve_once` repeatedly, restarting on
390/// return/error with bounded exponential backoff, until `shutdown` cancels.
391/// Factored out so the backoff/restart/shutdown behavior is unit-testable with a
392/// stub `serve_once` and tiny backoff constants (no real broker needed).
393async fn supervise_reconnect<F, Fut>(
394    mut serve_once: F,
395    shutdown: CancellationToken,
396    initial_backoff: Duration,
397    max_backoff: Duration,
398    reset_after: Duration,
399) where
400    F: FnMut() -> Fut + Send,
401    Fut: Future<Output = BrokerResult<()>> + Send,
402{
403    let mut backoff = initial_backoff;
404    loop {
405        // Race the in-flight serve against an intended shutdown so an otherwise
406        // indefinitely-blocked healthy connection still stops promptly.
407        let started = std::time::Instant::now();
408        let outcome = tokio::select! {
409            biased;
410            _ = shutdown.cancelled() => {
411                tracing::info!("MCP proxy supervisor: shutdown requested, stopping");
412                return;
413            }
414            r = serve_once() => r,
415        };
416
417        // A run that outlasted `reset_after` was healthy → reset backoff.
418        if started.elapsed() >= reset_after {
419            backoff = initial_backoff;
420        }
421
422        match outcome {
423            Ok(()) => tracing::warn!(
424                "MCP proxy connection ended; restarting (backoff {:?})",
425                backoff
426            ),
427            Err(e) => tracing::warn!(
428                "MCP proxy service errored: {e}; restarting (backoff {:?})",
429                backoff
430            ),
431        }
432
433        // Backoff sleep, abortable by shutdown so we don't linger after a stop.
434        let slept = tokio::select! {
435            biased;
436            _ = shutdown.cancelled() => false,
437            _ = tokio::time::sleep(backoff) => true,
438        };
439        if !slept {
440            tracing::info!("MCP proxy supervisor: shutdown during backoff, stopping");
441            return;
442        }
443        backoff = std::cmp::min(backoff * 2, max_backoff);
444    }
445}
446
447async fn handle_mcp_request(
448    backend: &dyn ToolExecutor,
449    allowlist: &RoleToolAllowlist,
450    msg: InboxMessage,
451) -> McpReply {
452    // The requesting worker's role scopes which host-bound tools it may proxy
453    // (issue #54). `None`/unlisted roles are unrestricted (back-compat).
454    let role = msg.from.role.as_deref();
455    match serde_json::from_value::<McpRequest>(msg.body) {
456        Ok(McpRequest::Manifest) => {
457            let tools = allowlist.filter_manifest(role, backend.list_tools());
458            if allowlist.is_restricted(role) {
459                tracing::debug!(
460                    role = role.unwrap_or("<none>"),
461                    tools = tools.len(),
462                    "mcp proxy: serving role-scoped manifest"
463                );
464            }
465            McpReply {
466                manifest: Some(tools),
467                ..Default::default()
468            }
469        }
470        Ok(McpRequest::Call { tool, arguments }) => {
471            // Defense in depth: the manifest already hides disallowed tools, but a
472            // worker could still try to call one directly — reject it here too.
473            if !allowlist.allows(role, &tool) {
474                tracing::warn!(
475                    role = role.unwrap_or("<none>"),
476                    tool = %tool,
477                    "mcp proxy: rejecting tool call not on role allowlist"
478                );
479                return McpReply {
480                    error: Some(format!(
481                        "tool '{tool}' is not allowed for role '{}'",
482                        role.unwrap_or("<none>")
483                    )),
484                    ..Default::default()
485                };
486            }
487            let call = ToolCall {
488                id: format!("mcp-{}", MsgId::new().as_str()),
489                tool_type: "function".to_string(),
490                function: FunctionCall {
491                    name: tool,
492                    arguments,
493                },
494            };
495            match backend.execute(&call).await {
496                Ok(r) => McpReply {
497                    result: Some(ProxiedResult {
498                        success: r.success,
499                        result: r.result,
500                    }),
501                    ..Default::default()
502                },
503                Err(e) => McpReply {
504                    error: Some(e.to_string()),
505                    ..Default::default()
506                },
507            }
508        }
509        Err(e) => McpReply {
510            error: Some(format!("bad mcp request: {e}")),
511            ..Default::default()
512        },
513    }
514}
515
516// ---- worker side --------------------------------------------------------------
517
518/// Worker-side proxy `ToolExecutor`: advertises the orchestrator's proxiable MCP
519/// tools and forwards calls to them over the broker.
520pub struct McpProxyExecutor {
521    /// The multiplexed driver over the proxy's broker sub-connection. A
522    /// `RwLock<Arc<…>>` so reconnect can SWAP the whole driver while in-flight
523    /// requests keep running on their cloned `Arc` of the old one. A request
524    /// clones the `Arc` and releases the lock BEFORE the round-trip, so parallel
525    /// MCP calls overlap instead of serializing behind one exclusive lock. #56.
526    client: tokio::sync::RwLock<Arc<MultiplexedClient>>,
527    /// Serializes reconnect attempts so concurrent callers don't each rebuild
528    /// the client. Held only across the (bounded) reconnect — the `client` lock
529    /// above is never held across a backoff sleep, so a reconnect can't deadlock
530    /// or stall an unrelated caller's lock acquisition.
531    reconnect_lock: Mutex<()>,
532    me: AgentRef,
533    endpoint: String,
534    token: String,
535    orchestrator: String,
536    /// Proxiable tool surface, refreshed on each (re)connect. Behind a sync
537    /// `RwLock` because `list_tools` is a sync trait method; reads/writes are
538    /// instantaneous (clone/swap a `Vec`), never held across an `.await`.
539    manifest: RwLock<Vec<ToolSchema>>,
540    timeout: Duration,
541}
542
543impl McpProxyExecutor {
544    /// Connect (as `proxy_id` — keep it distinct from the worker's main mailbox,
545    /// e.g. `<worker-id>#mcp`), fetch the proxiable-tool manifest from
546    /// `orchestrator`, and build. Returns a proxy advertising those tools.
547    ///
548    /// `role` is this worker's own role (e.g. `spec.identity.role`), advertised
549    /// on the `AgentRef` this connection Hello/subscribes with so the
550    /// orchestrator's [`RoleToolAllowlist`] (if configured) can scope the
551    /// manifest/calls to it — `None`/empty leaves the worker unrestricted
552    /// (matches pre-#54 behavior and any orchestrator with no allowlist
553    /// configured). Note this is SELF-ASSERTED by the worker, not
554    /// authenticated — see the trust-boundary section on
555    /// [`RoleToolAllowlist`]'s doc comment; do not treat it as a security
556    /// boundary against a malicious worker.
557    pub async fn connect(
558        endpoint: &str,
559        proxy_id: impl Into<String>,
560        role: Option<String>,
561        token: &str,
562        orchestrator: impl Into<String>,
563        timeout: Duration,
564    ) -> BrokerResult<Self> {
565        let me = AgentRef {
566            session_id: proxy_id.into(),
567            role,
568        };
569        let orchestrator = orchestrator.into();
570        let mut client = BrokerClient::connect(endpoint, me.clone(), token).await?;
571        client.subscribe().await?;
572        let mux = client.into_multiplexed(me.clone());
573
574        let reply = mux
575            .request(
576                &orchestrator,
577                InboxKind::McpRequest,
578                serde_json::to_value(McpRequest::Manifest).expect("McpRequest serializes"),
579                timeout,
580            )
581            .await?;
582        let reply: McpReply = serde_json::from_value(reply)
583            .map_err(|e| BrokerError::Protocol(format!("bad manifest reply: {e}")))?;
584        let manifest = reply.manifest.unwrap_or_default();
585
586        Ok(Self {
587            client: tokio::sync::RwLock::new(Arc::new(mux)),
588            reconnect_lock: Mutex::new(()),
589            me,
590            endpoint: endpoint.to_string(),
591            token: token.to_string(),
592            orchestrator,
593            manifest: RwLock::new(manifest),
594            timeout,
595        })
596    }
597
598    /// Number of proxiable tools advertised.
599    pub fn tool_count(&self) -> usize {
600        self.manifest.read().map(|m| m.len()).unwrap_or(0)
601    }
602
603    /// One request/reply over the current client (under its lock). Does NOT
604    /// reconnect — callers decide that from the error + connection state.
605    async fn request_once(&self, body: serde_json::Value) -> BrokerResult<serde_json::Value> {
606        // Clone the Arc and RELEASE the lock before the round-trip, so concurrent
607        // proxy calls overlap instead of serializing behind one exclusive lock.
608        let mux = self.client.read().await.clone();
609        mux.request(
610            &self.orchestrator,
611            InboxKind::McpRequest,
612            body,
613            self.timeout,
614        )
615        .await
616    }
617
618    /// Forward one MCP call, lazily reconnecting a single time if the broker
619    /// connection is *actually* broken (reader exited). A transient timeout or
620    /// an unrelated error is returned as-is — only a dead connection triggers
621    /// the reconnect+retry. On reconnect exhaustion a transient error is
622    /// returned (a later call may succeed), never a permanent one.
623    async fn request_with_reconnect(
624        &self,
625        body: serde_json::Value,
626    ) -> Result<serde_json::Value, ToolError> {
627        match self.request_once(body.clone()).await {
628            Ok(v) => Ok(v),
629            Err(first) => {
630                // Only worth a reconnect if the connection itself is gone; a
631                // plain timeout (reader still alive) surfaces directly.
632                if !self.connection_broken().await {
633                    return Err(ToolError::Execution(format!("mcp proxy: {first}")));
634                }
635                tracing::warn!("mcp proxy connection dropped; reconnecting: {first}");
636                self.reconnect_if_needed()
637                    .await
638                    .map_err(|re| ToolError::Execution(format!("mcp proxy (reconnect): {re}")))?;
639                // Retry exactly once over the freshly established client.
640                self.request_once(body)
641                    .await
642                    .map_err(|re| ToolError::Execution(format!("mcp proxy: {re}")))
643            }
644        }
645    }
646
647    /// `true` when the broker connection is known to be dead (the background
648    /// reader has exited). Used to decide whether a failed request is worth a
649    /// reconnect+retry rather than a plain error.
650    async fn connection_broken(&self) -> bool {
651        !self.client.read().await.reader_alive()
652    }
653
654    /// Lazily re-establish the broker sub-connection: re-Hello/Subscribe and
655    /// re-fetch the manifest, then swap in the fresh client. Serialized by
656    /// [`reconnect_lock`](Self.reconnect_lock); a concurrent caller that already
657    /// reconnected is a no-op. Bounded exponential backoff so a dead broker
658    /// can't hot-loop. Returns a *transient* error (not a permanent one) on
659    /// exhaustion, so a later call can try again — the executor is never
660    /// permanently disabled by a transient drop (issue #47).
661    async fn reconnect_if_needed(&self) -> BrokerResult<()> {
662        let _guard = self.reconnect_lock.lock().await;
663        // While we waited for the guard, another caller may already have
664        // rebuilt the client; if so, there is nothing to do.
665        if !self.connection_broken().await {
666            return Ok(());
667        }
668        let mut backoff = WORKER_RECONNECT_INITIAL_BACKOFF;
669        for _ in 0..WORKER_RECONNECT_MAX_ATTEMPTS {
670            match self.reconnect_once().await {
671                Ok(()) => return Ok(()),
672                Err(e) => {
673                    tracing::warn!("mcp proxy reconnect failed (backoff {:?}): {e}", backoff);
674                }
675            }
676            // Backoff WITHOUT holding the client mutex — only the reconnect
677            // serialization guard, which is the intended behavior (concurrent
678            // callers await the same single reconnect instead of racing).
679            tokio::time::sleep(backoff).await;
680            backoff = std::cmp::min(backoff * 2, WORKER_RECONNECT_MAX_BACKOFF);
681        }
682        Err(BrokerError::Transport(
683            "mcp proxy reconnect attempts exhausted".into(),
684        ))
685    }
686
687    /// One reconnect attempt: connect, subscribe, re-fetch the manifest, and
688    /// swap the new client + manifest into place. No backoff here — the caller
689    /// (`reconnect_if_needed`) owns the bounded retry/backoff loop.
690    async fn reconnect_once(&self) -> BrokerResult<()> {
691        let mut client =
692            BrokerClient::connect(&self.endpoint, self.me.clone(), &self.token).await?;
693        client.subscribe().await?;
694        let mux = client.into_multiplexed(self.me.clone());
695        // Re-fetch the manifest so any tool-surface change during the outage is
696        // reflected (the only state the proxy keeps beyond the live connection).
697        let reply = mux
698            .request(
699                &self.orchestrator,
700                InboxKind::McpRequest,
701                serde_json::to_value(McpRequest::Manifest).expect("McpRequest serializes"),
702                self.timeout,
703            )
704            .await?;
705        let reply: McpReply = serde_json::from_value(reply)
706            .map_err(|e| BrokerError::Protocol(format!("bad manifest reply: {e}")))?;
707        let manifest = reply.manifest.unwrap_or_default();
708        {
709            // Swap in the new driver. The old Arc lives until in-flight requests
710            // on it finish; its router ends when the old (dead) connection's
711            // reader closes `messages`, failing any stragglers. #56.
712            let mut slot = self.client.write().await;
713            *slot = Arc::new(mux);
714        }
715        if let Ok(mut m) = self.manifest.write() {
716            *m = manifest;
717        }
718        Ok(())
719    }
720}
721
722#[async_trait]
723impl ToolExecutor for McpProxyExecutor {
724    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
725        {
726            let manifest = self
727                .manifest
728                .read()
729                .map_err(|_| ToolError::Execution("mcp proxy manifest lock poisoned".into()))?;
730            if !manifest
731                .iter()
732                .any(|s| s.function.name == call.function.name)
733            {
734                return Err(ToolError::NotFound(call.function.name.clone()));
735            }
736        }
737        let body = serde_json::to_value(McpRequest::Call {
738            tool: call.function.name.clone(),
739            arguments: call.function.arguments.clone(),
740        })
741        .expect("McpRequest serializes");
742
743        // Forward the call, lazily reconnecting once if the broker connection
744        // is actually broken (reader exited). A healthy connection takes the
745        // fast path below — identical bytes to the pre-reconnect behavior.
746        let reply = self.request_with_reconnect(body).await?;
747
748        let reply: McpReply = serde_json::from_value(reply)
749            .map_err(|e| ToolError::Execution(format!("bad mcp reply: {e}")))?;
750        if let Some(err) = reply.error {
751            return Err(ToolError::Execution(err));
752        }
753        let r = reply
754            .result
755            .ok_or_else(|| ToolError::Execution("mcp reply missing result".to_string()))?;
756        Ok(ToolResult {
757            success: r.success,
758            result: r.result,
759            display_preference: None,
760            images: Vec::new(),
761        })
762    }
763
764    async fn execute_with_context(
765        &self,
766        call: &ToolCall,
767        _ctx: ToolExecutionContext<'_>,
768    ) -> Result<ToolResult, ToolError> {
769        self.execute(call).await
770    }
771
772    fn list_tools(&self) -> Vec<ToolSchema> {
773        self.manifest.read().map(|m| m.clone()).unwrap_or_default()
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::core::BrokerCore;
781    use crate::server::BrokerServer;
782    use bamboo_agent_core::tools::FunctionSchema;
783    use serde_json::json;
784    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
785    use tokio::net::TcpListener;
786
787    const TOKEN: &str = "t";
788
789    /// A stand-in for a host-bound MCP server: one tool that echoes its args.
790    struct StubMcp;
791
792    #[async_trait]
793    impl ToolExecutor for StubMcp {
794        async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
795            Ok(ToolResult {
796                success: true,
797                result: format!(
798                    "ran {} args={}",
799                    call.function.name, call.function.arguments
800                ),
801                display_preference: None,
802                images: Vec::new(),
803            })
804        }
805        async fn execute_with_context(
806            &self,
807            call: &ToolCall,
808            _ctx: ToolExecutionContext<'_>,
809        ) -> Result<ToolResult, ToolError> {
810            self.execute(call).await
811        }
812        fn list_tools(&self) -> Vec<ToolSchema> {
813            vec![ToolSchema {
814                schema_type: "function".into(),
815                function: FunctionSchema {
816                    name: "nova_click".into(),
817                    description: "click a mark".into(),
818                    parameters: json!({ "type": "object" }),
819                },
820            }]
821        }
822    }
823
824    /// A multi-tool host-bound MCP stub: a privileged screen tool + a benign one.
825    struct MultiToolMcp;
826
827    #[async_trait]
828    impl ToolExecutor for MultiToolMcp {
829        async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
830            Ok(ToolResult {
831                success: true,
832                result: format!("ran {}", call.function.name),
833                display_preference: None,
834                images: Vec::new(),
835            })
836        }
837        async fn execute_with_context(
838            &self,
839            call: &ToolCall,
840            _ctx: ToolExecutionContext<'_>,
841        ) -> Result<ToolResult, ToolError> {
842            self.execute(call).await
843        }
844        fn list_tools(&self) -> Vec<ToolSchema> {
845            ["nova_screenshot", "nova_click", "fetch_url"]
846                .into_iter()
847                .map(|name| ToolSchema {
848                    schema_type: "function".into(),
849                    function: FunctionSchema {
850                        name: name.into(),
851                        description: "t".into(),
852                        parameters: json!({ "type": "object" }),
853                    },
854                })
855                .collect()
856        }
857    }
858
859    /// Build a worker `McpRequest` inbox message with a given role, as the broker
860    /// would deliver it to the orchestrator's `handle_mcp_request`.
861    fn worker_request(role: Option<&str>, req: McpRequest) -> InboxMessage {
862        InboxMessage {
863            id: MsgId::new(),
864            from: AgentRef {
865                session_id: "worker#mcp".into(),
866                role: role.map(Into::into),
867            },
868            kind: InboxKind::McpRequest,
869            body: serde_json::to_value(req).unwrap(),
870            created_at: Utc::now(),
871            correlation_id: None,
872        }
873    }
874
875    fn manifest_names(reply: &McpReply) -> Vec<String> {
876        reply
877            .manifest
878            .as_ref()
879            .expect("manifest reply")
880            .iter()
881            .map(|t| t.function.name.clone())
882            .collect()
883    }
884
885    /// Issue #54: a role WITH an allowlist sees only its allowed tools in the
886    /// manifest; an unrestricted role (no entry / no role) sees ALL tools.
887    #[tokio::test]
888    async fn manifest_is_filtered_by_role_allowlist() {
889        let backend = MultiToolMcp;
890        // "researcher" may only proxy the benign fetch tool — not the screen tools.
891        let allowlist = RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"]);
892
893        // Restricted role → intersection only.
894        let reply = handle_mcp_request(
895            &backend,
896            &allowlist,
897            worker_request(Some("researcher"), McpRequest::Manifest),
898        )
899        .await;
900        assert_eq!(manifest_names(&reply), vec!["fetch_url".to_string()]);
901
902        // A role with no allowlist entry is unrestricted → all tools.
903        let reply = handle_mcp_request(
904            &backend,
905            &allowlist,
906            worker_request(Some("operator"), McpRequest::Manifest),
907        )
908        .await;
909        assert_eq!(manifest_names(&reply).len(), 3);
910
911        // No role at all is unrestricted too (back-compat) → all tools.
912        let reply = handle_mcp_request(
913            &backend,
914            &allowlist,
915            worker_request(None, McpRequest::Manifest),
916        )
917        .await;
918        assert_eq!(manifest_names(&reply).len(), 3);
919    }
920
921    /// Issue #54: defense in depth — a restricted role's `Call` for a tool not on
922    /// its allowlist is REJECTED (the manifest hides it, but a worker could still
923    /// try to call it directly). An allowed tool still executes.
924    #[tokio::test]
925    async fn call_is_rejected_when_tool_not_on_role_allowlist() {
926        let backend = MultiToolMcp;
927        let allowlist = RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"]);
928
929        // Disallowed tool → error, backend NOT invoked.
930        let reply = handle_mcp_request(
931            &backend,
932            &allowlist,
933            worker_request(
934                Some("researcher"),
935                McpRequest::Call {
936                    tool: "nova_screenshot".into(),
937                    arguments: "{}".into(),
938                },
939            ),
940        )
941        .await;
942        assert!(reply.result.is_none());
943        let err = reply.error.expect("a rejection error");
944        assert!(
945            err.contains("nova_screenshot") && err.contains("not allowed"),
946            "{err}"
947        );
948
949        // Allowed tool → executes normally.
950        let reply = handle_mcp_request(
951            &backend,
952            &allowlist,
953            worker_request(
954                Some("researcher"),
955                McpRequest::Call {
956                    tool: "fetch_url".into(),
957                    arguments: "{}".into(),
958                },
959            ),
960        )
961        .await;
962        assert!(reply.error.is_none());
963        assert_eq!(reply.result.expect("result").result, "ran fetch_url");
964
965        // Unrestricted role may call any tool.
966        let reply = handle_mcp_request(
967            &backend,
968            &allowlist,
969            worker_request(
970                None,
971                McpRequest::Call {
972                    tool: "nova_screenshot".into(),
973                    arguments: "{}".into(),
974                },
975            ),
976        )
977        .await;
978        assert!(reply.error.is_none());
979        assert_eq!(reply.result.expect("result").result, "ran nova_screenshot");
980    }
981
982    /// Issue #54 item 3: `from_config` validates at load time — a blank role
983    /// name is dropped, a duplicate role keeps the LATER entry, a blank tool
984    /// name is dropped, and an unknown tool name (vs. `known_tools`, standing
985    /// in for the backend's real tool set) is still KEPT/enforced (it may just
986    /// not be registered yet) rather than silently dropped. None of this
987    /// panics or errors — misconfiguration only warns (fails open per the
988    /// existing unrestricted-by-default posture), which this test asserts by
989    /// checking the resulting allowlist's effective behavior.
990    #[test]
991    fn from_config_validates_and_normalizes_entries() {
992        let known_tools: HashSet<String> = ["fetch_url", "nova_click"]
993            .into_iter()
994            .map(String::from)
995            .collect();
996        let allowlist = RoleToolAllowlist::from_config(
997            vec![
998                // Blank role: dropped entirely (can never match a real AgentRef.role).
999                ("   ".to_string(), vec!["fetch_url".to_string()]),
1000                // Duplicate role "researcher": the second entry wins.
1001                ("researcher".to_string(), vec!["nova_click".to_string()]),
1002                (
1003                    "researcher".to_string(),
1004                    vec!["fetch_url".to_string(), "  ".to_string()],
1005                ),
1006                // Unknown tool name (typo) vs `known_tools`: still enforced.
1007                ("typo-role".to_string(), vec!["fetch_urll".to_string()]),
1008            ],
1009            &known_tools,
1010        );
1011
1012        // Blank role never matches anything, so it is unrestricted (absent),
1013        // not restricted-to-nothing.
1014        assert!(!allowlist.is_restricted(Some("   ")));
1015
1016        // The LATER "researcher" entry (fetch_url, blank dropped) won, not the
1017        // earlier one (nova_click) — and the blank tool name was dropped.
1018        assert!(allowlist.allows(Some("researcher"), "fetch_url"));
1019        assert!(!allowlist.allows(Some("researcher"), "nova_click"));
1020
1021        // A tool name absent from `known_tools` (a likely typo) is still
1022        // enforced — presence in `known_tools` is a validation SIGNAL
1023        // (warned), not a hard filter.
1024        assert!(allowlist.allows(Some("typo-role"), "fetch_urll"));
1025        assert!(!allowlist.allows(Some("typo-role"), "fetch_url"));
1026    }
1027
1028    /// `from_config` with an EMPTY `known_tools` set (e.g. the backend's tool
1029    /// list is not available yet at config-load time) skips tool-name
1030    /// validation entirely rather than flagging every configured tool as
1031    /// unknown — the allowlist itself is unaffected either way.
1032    #[test]
1033    fn from_config_skips_tool_validation_when_known_tools_is_empty() {
1034        let allowlist = RoleToolAllowlist::from_config(
1035            vec![("researcher", vec!["anything_at_all"])],
1036            &HashSet::new(),
1037        );
1038        assert!(allowlist.allows(Some("researcher"), "anything_at_all"));
1039        assert!(!allowlist.allows(Some("researcher"), "other_tool"));
1040    }
1041
1042    /// A role mapped to an EMPTY allowlist is an explicit lockout (no tools),
1043    /// distinct from an absent role (unrestricted).
1044    #[tokio::test]
1045    async fn empty_allowlist_entry_is_explicit_lockout() {
1046        let backend = MultiToolMcp;
1047        let allowlist = RoleToolAllowlist::from_entries(vec![("sandbox", Vec::<String>::new())]);
1048        let reply = handle_mcp_request(
1049            &backend,
1050            &allowlist,
1051            worker_request(Some("sandbox"), McpRequest::Manifest),
1052        )
1053        .await;
1054        assert!(manifest_names(&reply).is_empty());
1055    }
1056
1057    #[tokio::test]
1058    async fn proxy_lists_and_forwards_calls_over_the_broker() {
1059        let dir = tempfile::tempdir().unwrap();
1060        let core = Arc::new(BrokerCore::new(dir.path()));
1061        let server = Arc::new(BrokerServer::new(core, TOKEN));
1062        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1063        let addr = listener.local_addr().unwrap();
1064        tokio::spawn(async move {
1065            let _ = server.serve(listener).await;
1066        });
1067        let endpoint = format!("ws://{addr}");
1068
1069        // Orchestrator runs the proxy service backed by the stub host-bound MCP.
1070        let ep = endpoint.clone();
1071        tokio::spawn(async move {
1072            let _ = serve_mcp_proxy(
1073                &ep,
1074                AgentRef {
1075                    session_id: "orchestrator".into(),
1076                    role: None,
1077                },
1078                TOKEN,
1079                Arc::new(StubMcp),
1080                Arc::new(RoleToolAllowlist::unrestricted()),
1081            )
1082            .await;
1083        });
1084
1085        // Worker builds a proxy: it fetches the manifest and advertises the tool.
1086        let proxy = McpProxyExecutor::connect(
1087            &endpoint,
1088            "worker#mcp",
1089            None,
1090            TOKEN,
1091            "orchestrator",
1092            Duration::from_secs(5),
1093        )
1094        .await
1095        .expect("proxy connects + fetches manifest");
1096        let tools = proxy.list_tools();
1097        assert_eq!(tools.len(), 1);
1098        assert_eq!(tools[0].function.name, "nova_click");
1099
1100        // A call is forwarded to the orchestrator and the result comes back.
1101        let call = ToolCall {
1102            id: "c1".into(),
1103            tool_type: "function".into(),
1104            function: FunctionCall {
1105                name: "nova_click".into(),
1106                arguments: "{\"mark\":7}".into(),
1107            },
1108        };
1109        let result = proxy.execute(&call).await.expect("proxied call returns");
1110        assert!(result.success);
1111        assert_eq!(result.result, "ran nova_click args={\"mark\":7}");
1112
1113        // Unknown tools are not handled by the proxy.
1114        let miss = ToolCall {
1115            id: "c2".into(),
1116            tool_type: "function".into(),
1117            function: FunctionCall {
1118                name: "not_proxied".into(),
1119                arguments: "{}".into(),
1120            },
1121        };
1122        assert!(matches!(
1123            proxy.execute(&miss).await,
1124            Err(ToolError::NotFound(_))
1125        ));
1126    }
1127
1128    /// Issue #54, REAL path: drives `RoleToolAllowlist` filtering through the
1129    /// ACTUAL production wiring — a real broker, `serve_mcp_proxy` reading the
1130    /// role off the connecting worker's `AgentRef` (not `handle_mcp_request`
1131    /// called directly with an explicit allowlist), and `McpProxyExecutor::
1132    /// connect` advertising the worker's real role. Exercising only
1133    /// `handle_mcp_request` (as the other unit tests in this module do) would
1134    /// mask the exact bug #54 was reopened for: the role never reaching the
1135    /// orchestrator-side filter in production because nothing threaded it
1136    /// through the real connect/serve path.
1137    #[tokio::test]
1138    async fn proxy_executor_role_is_filtered_end_to_end_over_the_real_broker() {
1139        let dir = tempfile::tempdir().unwrap();
1140        let core = Arc::new(BrokerCore::new(dir.path()));
1141        let server = Arc::new(BrokerServer::new(core, TOKEN));
1142        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1143        let addr = listener.local_addr().unwrap();
1144        tokio::spawn(async move {
1145            let _ = server.serve(listener).await;
1146        });
1147        let endpoint = format!("ws://{addr}");
1148
1149        // Orchestrator: "researcher" may only proxy the benign fetch tool.
1150        let ep = endpoint.clone();
1151        tokio::spawn(async move {
1152            let _ = serve_mcp_proxy(
1153                &ep,
1154                AgentRef {
1155                    session_id: "orchestrator".into(),
1156                    role: None,
1157                },
1158                TOKEN,
1159                Arc::new(MultiToolMcp),
1160                Arc::new(RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"])),
1161            )
1162            .await;
1163        });
1164
1165        // A worker connecting AS "researcher" — over the real broker, with its
1166        // role threaded through `McpProxyExecutor::connect` exactly as
1167        // `subagent_worker.rs` does in production — must see only its role's
1168        // allowed tool in the manifest it fetches on connect.
1169        let restricted = McpProxyExecutor::connect(
1170            &endpoint,
1171            "worker-researcher#mcp",
1172            Some("researcher".to_string()),
1173            TOKEN,
1174            "orchestrator",
1175            Duration::from_secs(5),
1176        )
1177        .await
1178        .expect("restricted proxy connects + fetches its role-scoped manifest");
1179        let tools = restricted.list_tools();
1180        assert_eq!(
1181            tools
1182                .iter()
1183                .map(|t| t.function.name.clone())
1184                .collect::<Vec<_>>(),
1185            vec!["fetch_url".to_string()],
1186            "a role WITH an allowlist entry must see only its allowed tools over the real path"
1187        );
1188
1189        // The allowed tool executes normally...
1190        let ok_call = ToolCall {
1191            id: "c1".into(),
1192            tool_type: "function".into(),
1193            function: FunctionCall {
1194                name: "fetch_url".into(),
1195                arguments: "{}".into(),
1196            },
1197        };
1198        let result = restricted
1199            .execute(&ok_call)
1200            .await
1201            .expect("allowed tool call succeeds");
1202        assert!(result.success);
1203
1204        // ...but a disallowed tool is invisible in the manifest, so the LOCAL
1205        // "not in my manifest" check rejects it before it is even sent —
1206        // proving the manifest filtering (not just the orchestrator's
1207        // defense-in-depth Call check) actually took effect end-to-end.
1208        let denied_call = ToolCall {
1209            id: "c2".into(),
1210            tool_type: "function".into(),
1211            function: FunctionCall {
1212                name: "nova_screenshot".into(),
1213                arguments: "{}".into(),
1214            },
1215        };
1216        assert!(matches!(
1217            restricted.execute(&denied_call).await,
1218            Err(ToolError::NotFound(_))
1219        ));
1220
1221        // A worker connecting with NO role (unrestricted, back-compat) still
1222        // sees the full tool set over the same real orchestrator/allowlist.
1223        let unrestricted = McpProxyExecutor::connect(
1224            &endpoint,
1225            "worker-unrestricted#mcp",
1226            None,
1227            TOKEN,
1228            "orchestrator",
1229            Duration::from_secs(5),
1230        )
1231        .await
1232        .expect("unrestricted proxy connects");
1233        assert_eq!(unrestricted.list_tools().len(), 3);
1234    }
1235
1236    #[tokio::test]
1237    async fn proxy_handles_concurrent_calls_correctly() {
1238        let dir = tempfile::tempdir().unwrap();
1239        let core = Arc::new(BrokerCore::new(dir.path()));
1240        let server = Arc::new(BrokerServer::new(core, TOKEN));
1241        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1242        let addr = listener.local_addr().unwrap();
1243        tokio::spawn(async move {
1244            let _ = server.serve(listener).await;
1245        });
1246        let endpoint = format!("ws://{addr}");
1247
1248        let ep = endpoint.clone();
1249        tokio::spawn(async move {
1250            let _ = serve_mcp_proxy(
1251                &ep,
1252                AgentRef {
1253                    session_id: "orchestrator".into(),
1254                    role: None,
1255                },
1256                TOKEN,
1257                Arc::new(StubMcp),
1258                Arc::new(RoleToolAllowlist::unrestricted()),
1259            )
1260            .await;
1261        });
1262
1263        let proxy = Arc::new(
1264            McpProxyExecutor::connect(
1265                &endpoint,
1266                "worker#mcp",
1267                None,
1268                TOKEN,
1269                "orchestrator",
1270                Duration::from_secs(5),
1271            )
1272            .await
1273            .expect("proxy connects"),
1274        );
1275
1276        // Fire N concurrent proxied calls with DISTINCT args over the multiplexed
1277        // connection (no per-call exclusive lock). Each must get its OWN result —
1278        // proving concurrent execute() doesn't serialize-deadlock or mis-route
1279        // replies. (End-to-end latency is still capped by the serial orchestrator
1280        // serve_mcp_proxy — that's the complementary half, tracked separately.) #56.
1281        let mut handles = Vec::new();
1282        for i in 0..8u32 {
1283            let p = proxy.clone();
1284            handles.push(tokio::spawn(async move {
1285                let call = ToolCall {
1286                    id: format!("c{i}"),
1287                    tool_type: "function".into(),
1288                    function: FunctionCall {
1289                        name: "nova_click".into(),
1290                        arguments: format!("{{\"mark\":{i}}}"),
1291                    },
1292                };
1293                let r = p.execute(&call).await.expect("proxied call returns");
1294                (i, r.result)
1295            }));
1296        }
1297        for h in handles {
1298            let (i, result) = h.await.unwrap();
1299            assert_eq!(result, format!("ran nova_click args={{\"mark\":{i}}}"));
1300        }
1301    }
1302
1303    #[tokio::test]
1304    async fn concurrent_proxy_calls_overlap_at_the_orchestrator() {
1305        // Prove overlap DIRECTLY (issue #486) instead of inferring it from
1306        // wall-clock duration: this originally asserted `elapsed < N ms`
1307        // against a `sleep(200ms)` backend, which raced CI load. Then #502
1308        // switched to a high-water-mark (`fetch_max` of instantaneous
1309        // `in_flight`), which is deterministic in the "genuinely serial"
1310        // direction but still racy in the "CPU-starved" direction: under
1311        // extreme scheduler pressure the 4 spawned calls might never all
1312        // actually be polled at once, so the observed high-water mark can
1313        // top out below 4 even though the orchestrator itself doesn't
1314        // serialize anything — a false failure with no code regression.
1315        //
1316        // Fix: force the rendezvous instead of hoping for it. `SlowMcp`
1317        // bumps `in_flight`/`max_in_flight` and then blocks each call on a
1318        // `Barrier` sized for all 4 concurrent calls — nobody proceeds until
1319        // all 4 have arrived. That makes `max_in_flight == 4` deterministic
1320        // regardless of how loaded the host is. If the orchestrator ever
1321        // regresses to serializing these calls, only 1 of the 4 barrier
1322        // parties will ever arrive and the wait deadlocks — bounded by the
1323        // outer `tokio::time::timeout` below, which turns that regression
1324        // into a clear, fast test failure instead of a hang.
1325        struct SlowMcp {
1326            in_flight: AtomicU32,
1327            max_in_flight: AtomicU32,
1328            rendezvous: tokio::sync::Barrier,
1329        }
1330        #[async_trait]
1331        impl ToolExecutor for SlowMcp {
1332            async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
1333                let now_in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1334                self.max_in_flight
1335                    .fetch_max(now_in_flight, Ordering::SeqCst);
1336                // Forced rendezvous: block until all 4 concurrent calls have
1337                // arrived here. This is what makes the overlap deterministic
1338                // rather than probabilistic.
1339                self.rendezvous.wait().await;
1340                tokio::time::sleep(Duration::from_millis(50)).await;
1341                self.in_flight.fetch_sub(1, Ordering::SeqCst);
1342                Ok(ToolResult {
1343                    success: true,
1344                    result: format!("done {}", call.function.arguments),
1345                    display_preference: None,
1346                    images: Vec::new(),
1347                })
1348            }
1349            async fn execute_with_context(
1350                &self,
1351                call: &ToolCall,
1352                _ctx: ToolExecutionContext<'_>,
1353            ) -> Result<ToolResult, ToolError> {
1354                self.execute(call).await
1355            }
1356            fn list_tools(&self) -> Vec<ToolSchema> {
1357                vec![ToolSchema {
1358                    schema_type: "function".into(),
1359                    function: FunctionSchema {
1360                        name: "nova_click".into(),
1361                        description: "click a mark".into(),
1362                        parameters: json!({ "type": "object" }),
1363                    },
1364                }]
1365            }
1366        }
1367        const N: u32 = 4;
1368        let slow_mcp = Arc::new(SlowMcp {
1369            in_flight: AtomicU32::new(0),
1370            max_in_flight: AtomicU32::new(0),
1371            rendezvous: tokio::sync::Barrier::new(N as usize),
1372        });
1373
1374        let dir = tempfile::tempdir().unwrap();
1375        let core = Arc::new(BrokerCore::new(dir.path()));
1376        let server = Arc::new(BrokerServer::new(core, TOKEN));
1377        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1378        let addr = listener.local_addr().unwrap();
1379        tokio::spawn(async move {
1380            let _ = server.serve(listener).await;
1381        });
1382        let endpoint = format!("ws://{addr}");
1383
1384        let ep = endpoint.clone();
1385        let mcp_for_server = slow_mcp.clone();
1386        tokio::spawn(async move {
1387            let _ = serve_mcp_proxy(
1388                &ep,
1389                AgentRef {
1390                    session_id: "orchestrator".into(),
1391                    role: None,
1392                },
1393                TOKEN,
1394                mcp_for_server,
1395                Arc::new(RoleToolAllowlist::unrestricted()),
1396            )
1397            .await;
1398        });
1399
1400        let proxy = Arc::new(
1401            McpProxyExecutor::connect(
1402                &endpoint,
1403                "worker#mcp",
1404                None,
1405                TOKEN,
1406                "orchestrator",
1407                Duration::from_secs(5),
1408            )
1409            .await
1410            .expect("proxy connects"),
1411        );
1412
1413        // N concurrent 50ms calls, forced to rendezvous inside `SlowMcp`;
1414        // `max_in_flight` records the high-water mark of calls it observed
1415        // simultaneously in progress.
1416        let mut handles = Vec::new();
1417        for i in 0..N {
1418            let p = proxy.clone();
1419            handles.push(tokio::spawn(async move {
1420                let call = ToolCall {
1421                    id: format!("c{i}"),
1422                    tool_type: "function".into(),
1423                    function: FunctionCall {
1424                        name: "nova_click".into(),
1425                        arguments: format!("{{\"i\":{i}}}"),
1426                    },
1427                };
1428                p.execute(&call).await.expect("returns")
1429            }));
1430        }
1431        tokio::time::timeout(Duration::from_secs(20), async {
1432            for h in handles {
1433                h.await.unwrap();
1434            }
1435        })
1436        .await
1437        .expect(
1438            "timed out waiting for the 4 concurrent proxy calls to complete — this means \
1439             the orchestrator is serializing them (only some of the 4 ever reached the \
1440             rendezvous barrier), which is the regression this test guards against",
1441        );
1442        let max_in_flight = slow_mcp.max_in_flight.load(Ordering::SeqCst);
1443        assert_eq!(
1444            max_in_flight, N,
1445            "{N} concurrent proxy calls must OVERLAP at the orchestrator (serial handling \
1446             could never observe more than 1 in flight at once); observed max_in_flight = \
1447             {max_in_flight}"
1448        );
1449    }
1450
1451    // --- issue #47: supervised reconnect on both sides ------------------------
1452
1453    /// Orchestrator supervisor: it restarts `serve_once` after each return with
1454    /// bounded backoff, and stops cleanly on shutdown. Driven with a stub
1455    /// `serve_once` (no real broker) and tiny backoff constants so it is fast
1456    /// and deterministic.
1457    #[tokio::test]
1458    async fn supervisor_restarts_on_drop_and_stops_on_shutdown() {
1459        let shutdown = CancellationToken::new();
1460        let calls = Arc::new(AtomicU32::new(0));
1461        let calls_for_serve = calls.clone();
1462        // serve_once: succeed quickly 3 times (quick restarts), then block
1463        // forever — a "healthy", long-lived connection that only ends on cancel.
1464        let serve = move || {
1465            let c = calls_for_serve.clone();
1466            async move {
1467                let n = c.fetch_add(1, Ordering::SeqCst);
1468                if n < 3 {
1469                    Ok(())
1470                } else {
1471                    std::future::pending::<BrokerResult<()>>().await
1472                }
1473            }
1474        };
1475
1476        let started = std::time::Instant::now();
1477        let task = tokio::spawn(supervise_reconnect(
1478            serve,
1479            shutdown.clone(),
1480            Duration::from_millis(2),
1481            Duration::from_millis(8),
1482            Duration::from_secs(60), // runs are instant → never "healthy", backoff grows
1483        ));
1484
1485        // 4 serve calls = 3 quick restarts + 1 blocking run, all within the
1486        // bounded backoff window (not e.g. 30s — proves the backoff is bounded).
1487        tokio::time::timeout(Duration::from_secs(3), async {
1488            while calls.load(Ordering::SeqCst) < 4 {
1489                tokio::task::yield_now().await;
1490            }
1491        })
1492        .await
1493        .expect("supervisor restarted within bounded backoff");
1494        assert!(calls.load(Ordering::SeqCst) >= 4);
1495        assert!(
1496            started.elapsed() < Duration::from_secs(3),
1497            "restarts were bounded-fast, took {:?}",
1498            started.elapsed()
1499        );
1500
1501        // Shutdown interrupts the blocking serve (+ any backoff) and ends the loop.
1502        shutdown.cancel();
1503        tokio::time::timeout(Duration::from_secs(3), task)
1504            .await
1505            .expect("supervisor stops promptly on shutdown")
1506            .expect("supervisor task did not panic");
1507    }
1508
1509    /// Build the correlated `McpReply` for one worker `McpRequest` (used by the
1510    /// stand-in broker below).
1511    fn answer_mcp_request(req_msg: InboxMessage, orch: &AgentRef) -> InboxMessage {
1512        let reply_body = match serde_json::from_value::<McpRequest>(req_msg.body) {
1513            Ok(McpRequest::Manifest) => McpReply {
1514                manifest: Some(vec![ToolSchema {
1515                    schema_type: "function".into(),
1516                    function: FunctionSchema {
1517                        name: "nova_click".into(),
1518                        description: "click a mark".into(),
1519                        parameters: json!({ "type": "object" }),
1520                    },
1521                }]),
1522                ..Default::default()
1523            },
1524            Ok(McpRequest::Call { tool, arguments }) => McpReply {
1525                result: Some(ProxiedResult {
1526                    success: true,
1527                    result: format!("ran {tool} args={arguments}"),
1528                }),
1529                ..Default::default()
1530            },
1531            Err(_) => McpReply {
1532                error: Some("bad mcp request".into()),
1533                ..Default::default()
1534            },
1535        };
1536        InboxMessage {
1537            id: MsgId::new(),
1538            from: orch.clone(),
1539            kind: InboxKind::McpReply,
1540            body: serde_json::to_value(reply_body).unwrap_or_default(),
1541            created_at: Utc::now(),
1542            correlation_id: Some(req_msg.id),
1543        }
1544    }
1545
1546    /// A stand-in broker that ALSO answers `McpRequest`s as the orchestrator
1547    /// would (the worker can't tell the difference — it just needs correlated
1548    /// replies). The FIRST accepted connection serves the connect `Manifest` +
1549    /// one `Call`, then closes the socket — simulating a transient WebSocket
1550    /// drop. Subsequent connections (reconnects) stay open and keep answering.
1551    /// Returns (endpoint, connections-accepted-counter).
1552    async fn flaky_mcp_broker() -> (String, Arc<AtomicU32>) {
1553        use futures_util::{SinkExt, StreamExt};
1554        use tokio_tungstenite::accept_async;
1555        use tokio_tungstenite::tungstenite::Message;
1556
1557        use crate::proto::{BrokerFrame, ClientFrame};
1558
1559        let orch = AgentRef {
1560            session_id: "orchestrator".into(),
1561            role: None,
1562        };
1563        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1564        let addr = listener.local_addr().unwrap();
1565        let first_taken = Arc::new(AtomicBool::new(false));
1566        let conns = Arc::new(AtomicU32::new(0));
1567        let conns_for_loop = conns.clone();
1568        tokio::spawn(async move {
1569            loop {
1570                let (stream, _) = match listener.accept().await {
1571                    Ok(s) => s,
1572                    Err(_) => break,
1573                };
1574                let is_first = !first_taken.swap(true, Ordering::SeqCst);
1575                conns_for_loop.fetch_add(1, Ordering::SeqCst);
1576                let orch = orch.clone();
1577                tokio::spawn(async move {
1578                    let ws = match accept_async(stream).await {
1579                        Ok(ws) => ws,
1580                        Err(_) => return,
1581                    };
1582                    let (mut sink, mut source) = ws.split();
1583
1584                    // 1. Hello → Welcome.
1585                    while let Some(Ok(msg)) = source.next().await {
1586                        if let Message::Text(t) = msg {
1587                            if ClientFrame::from_text(&t).is_ok() {
1588                                let _ = sink
1589                                    .send(Message::text(BrokerFrame::Welcome.to_text()))
1590                                    .await;
1591                                break;
1592                            }
1593                        }
1594                    }
1595
1596                    // 2. Serve Deliver frames. The first connection closes right
1597                    //    after it has answered the connect Manifest + one Call
1598                    //    (the simulated blip). Reconnects stay open.
1599                    let mut delivered = 0u32;
1600                    while let Some(Ok(msg)) = source.next().await {
1601                        let message = match msg {
1602                            Message::Text(t) => match ClientFrame::from_text(&t) {
1603                                Ok(ClientFrame::Deliver { message, .. }) => message,
1604                                _ => continue,
1605                            },
1606                            _ => continue,
1607                        };
1608                        let _ = sink
1609                            .send(Message::text(
1610                                BrokerFrame::Delivered {
1611                                    id: message.id.clone(),
1612                                }
1613                                .to_text(),
1614                            ))
1615                            .await;
1616                        let reply = answer_mcp_request(message, &orch);
1617                        let _ = sink
1618                            .send(Message::text(
1619                                BrokerFrame::Message { message: reply }.to_text(),
1620                            ))
1621                            .await;
1622                        if is_first {
1623                            delivered += 1;
1624                            if delivered == 2 {
1625                                // manifest + call served → drop the connection
1626                                let _ = sink.send(Message::Close(None)).await;
1627                                break;
1628                            }
1629                        }
1630                    }
1631                });
1632            }
1633        });
1634        (format!("ws://{addr}"), conns)
1635    }
1636
1637    /// Worker reconnect (issue #47): after the broker connection drops mid-run,
1638    /// a later call transparently reconnects and succeeds instead of surfacing a
1639    /// permanent transport error.
1640    #[tokio::test]
1641    async fn proxy_executor_reconnects_after_transient_drop() {
1642        let (endpoint, conns) = flaky_mcp_broker().await;
1643
1644        let proxy = McpProxyExecutor::connect(
1645            &endpoint,
1646            "worker#mcp",
1647            None,
1648            TOKEN,
1649            "orchestrator",
1650            Duration::from_secs(5),
1651        )
1652        .await
1653        .expect("proxy connects + fetches manifest");
1654        assert_eq!(proxy.list_tools().len(), 1);
1655
1656        let call = |n: usize| ToolCall {
1657            id: format!("c{n}"),
1658            tool_type: "function".into(),
1659            function: FunctionCall {
1660                name: "nova_click".into(),
1661                arguments: "{}".into(),
1662            },
1663        };
1664
1665        // conn1: the first call succeeds before the (simulated) drop.
1666        let r1 = proxy.execute(&call(1)).await.expect("call1 on conn1");
1667        assert!(r1.success);
1668
1669        // conn1 is now closed. This call must lazily reconnect (conn2) and retry
1670        // — NOT return a permanent "connection closed before reply" error.
1671        let r2 = tokio::time::timeout(Duration::from_secs(15), proxy.execute(&call(2)))
1672            .await
1673            .expect("call2 did not hang")
1674            .expect("call2 succeeds after reconnect (not a permanent error)");
1675        assert!(r2.success);
1676        assert_eq!(r2.result, "ran nova_click args={}");
1677
1678        // The reconnect opened a second broker connection.
1679        assert!(
1680            conns.load(Ordering::SeqCst) >= 2,
1681            "worker reconnected (>=2 connections accepted), got {}",
1682            conns.load(Ordering::SeqCst)
1683        );
1684
1685        // A third call on the (now healthy) reconnected connection succeeds too,
1686        // proving the executor is not permanently disabled by the drop.
1687        let r3 = proxy.execute(&call(3)).await.expect("call3 on conn2");
1688        assert!(r3.success);
1689    }
1690}