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    fn owns_exact_tool(&self, tool_name: &str) -> bool {
777        self.manifest
778            .read()
779            .map(|manifest| {
780                manifest
781                    .iter()
782                    .any(|schema| schema.function.name == tool_name)
783            })
784            .unwrap_or(false)
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use crate::core::BrokerCore;
792    use crate::server::BrokerServer;
793    use bamboo_agent_core::tools::FunctionSchema;
794    use serde_json::json;
795    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
796    use tokio::net::TcpListener;
797
798    const TOKEN: &str = "t";
799
800    /// A stand-in for a host-bound MCP server: one tool that echoes its args.
801    struct StubMcp;
802
803    #[async_trait]
804    impl ToolExecutor for StubMcp {
805        async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
806            Ok(ToolResult {
807                success: true,
808                result: format!(
809                    "ran {} args={}",
810                    call.function.name, call.function.arguments
811                ),
812                display_preference: None,
813                images: Vec::new(),
814            })
815        }
816        async fn execute_with_context(
817            &self,
818            call: &ToolCall,
819            _ctx: ToolExecutionContext<'_>,
820        ) -> Result<ToolResult, ToolError> {
821            self.execute(call).await
822        }
823        fn list_tools(&self) -> Vec<ToolSchema> {
824            vec![ToolSchema {
825                schema_type: "function".into(),
826                function: FunctionSchema {
827                    name: "nova_click".into(),
828                    description: "click a mark".into(),
829                    parameters: json!({ "type": "object" }),
830                },
831            }]
832        }
833    }
834
835    /// A multi-tool host-bound MCP stub: a privileged screen tool + a benign one.
836    struct MultiToolMcp;
837
838    #[async_trait]
839    impl ToolExecutor for MultiToolMcp {
840        async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
841            Ok(ToolResult {
842                success: true,
843                result: format!("ran {}", call.function.name),
844                display_preference: None,
845                images: Vec::new(),
846            })
847        }
848        async fn execute_with_context(
849            &self,
850            call: &ToolCall,
851            _ctx: ToolExecutionContext<'_>,
852        ) -> Result<ToolResult, ToolError> {
853            self.execute(call).await
854        }
855        fn list_tools(&self) -> Vec<ToolSchema> {
856            ["nova_screenshot", "nova_click", "fetch_url"]
857                .into_iter()
858                .map(|name| ToolSchema {
859                    schema_type: "function".into(),
860                    function: FunctionSchema {
861                        name: name.into(),
862                        description: "t".into(),
863                        parameters: json!({ "type": "object" }),
864                    },
865                })
866                .collect()
867        }
868    }
869
870    /// Build a worker `McpRequest` inbox message with a given role, as the broker
871    /// would deliver it to the orchestrator's `handle_mcp_request`.
872    fn worker_request(role: Option<&str>, req: McpRequest) -> InboxMessage {
873        InboxMessage {
874            id: MsgId::new(),
875            from: AgentRef {
876                session_id: "worker#mcp".into(),
877                role: role.map(Into::into),
878            },
879            kind: InboxKind::McpRequest,
880            body: serde_json::to_value(req).unwrap(),
881            created_at: Utc::now(),
882            correlation_id: None,
883        }
884    }
885
886    fn manifest_names(reply: &McpReply) -> Vec<String> {
887        reply
888            .manifest
889            .as_ref()
890            .expect("manifest reply")
891            .iter()
892            .map(|t| t.function.name.clone())
893            .collect()
894    }
895
896    /// Issue #54: a role WITH an allowlist sees only its allowed tools in the
897    /// manifest; an unrestricted role (no entry / no role) sees ALL tools.
898    #[tokio::test]
899    async fn manifest_is_filtered_by_role_allowlist() {
900        let backend = MultiToolMcp;
901        // "researcher" may only proxy the benign fetch tool — not the screen tools.
902        let allowlist = RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"]);
903
904        // Restricted role → intersection only.
905        let reply = handle_mcp_request(
906            &backend,
907            &allowlist,
908            worker_request(Some("researcher"), McpRequest::Manifest),
909        )
910        .await;
911        assert_eq!(manifest_names(&reply), vec!["fetch_url".to_string()]);
912
913        // A role with no allowlist entry is unrestricted → all tools.
914        let reply = handle_mcp_request(
915            &backend,
916            &allowlist,
917            worker_request(Some("operator"), McpRequest::Manifest),
918        )
919        .await;
920        assert_eq!(manifest_names(&reply).len(), 3);
921
922        // No role at all is unrestricted too (back-compat) → all tools.
923        let reply = handle_mcp_request(
924            &backend,
925            &allowlist,
926            worker_request(None, McpRequest::Manifest),
927        )
928        .await;
929        assert_eq!(manifest_names(&reply).len(), 3);
930    }
931
932    /// Issue #54: defense in depth — a restricted role's `Call` for a tool not on
933    /// its allowlist is REJECTED (the manifest hides it, but a worker could still
934    /// try to call it directly). An allowed tool still executes.
935    #[tokio::test]
936    async fn call_is_rejected_when_tool_not_on_role_allowlist() {
937        let backend = MultiToolMcp;
938        let allowlist = RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"]);
939
940        // Disallowed tool → error, backend NOT invoked.
941        let reply = handle_mcp_request(
942            &backend,
943            &allowlist,
944            worker_request(
945                Some("researcher"),
946                McpRequest::Call {
947                    tool: "nova_screenshot".into(),
948                    arguments: "{}".into(),
949                },
950            ),
951        )
952        .await;
953        assert!(reply.result.is_none());
954        let err = reply.error.expect("a rejection error");
955        assert!(
956            err.contains("nova_screenshot") && err.contains("not allowed"),
957            "{err}"
958        );
959
960        // Allowed tool → executes normally.
961        let reply = handle_mcp_request(
962            &backend,
963            &allowlist,
964            worker_request(
965                Some("researcher"),
966                McpRequest::Call {
967                    tool: "fetch_url".into(),
968                    arguments: "{}".into(),
969                },
970            ),
971        )
972        .await;
973        assert!(reply.error.is_none());
974        assert_eq!(reply.result.expect("result").result, "ran fetch_url");
975
976        // Unrestricted role may call any tool.
977        let reply = handle_mcp_request(
978            &backend,
979            &allowlist,
980            worker_request(
981                None,
982                McpRequest::Call {
983                    tool: "nova_screenshot".into(),
984                    arguments: "{}".into(),
985                },
986            ),
987        )
988        .await;
989        assert!(reply.error.is_none());
990        assert_eq!(reply.result.expect("result").result, "ran nova_screenshot");
991    }
992
993    /// Issue #54 item 3: `from_config` validates at load time — a blank role
994    /// name is dropped, a duplicate role keeps the LATER entry, a blank tool
995    /// name is dropped, and an unknown tool name (vs. `known_tools`, standing
996    /// in for the backend's real tool set) is still KEPT/enforced (it may just
997    /// not be registered yet) rather than silently dropped. None of this
998    /// panics or errors — misconfiguration only warns (fails open per the
999    /// existing unrestricted-by-default posture), which this test asserts by
1000    /// checking the resulting allowlist's effective behavior.
1001    #[test]
1002    fn from_config_validates_and_normalizes_entries() {
1003        let known_tools: HashSet<String> = ["fetch_url", "nova_click"]
1004            .into_iter()
1005            .map(String::from)
1006            .collect();
1007        let allowlist = RoleToolAllowlist::from_config(
1008            vec![
1009                // Blank role: dropped entirely (can never match a real AgentRef.role).
1010                ("   ".to_string(), vec!["fetch_url".to_string()]),
1011                // Duplicate role "researcher": the second entry wins.
1012                ("researcher".to_string(), vec!["nova_click".to_string()]),
1013                (
1014                    "researcher".to_string(),
1015                    vec!["fetch_url".to_string(), "  ".to_string()],
1016                ),
1017                // Unknown tool name (typo) vs `known_tools`: still enforced.
1018                ("typo-role".to_string(), vec!["fetch_urll".to_string()]),
1019            ],
1020            &known_tools,
1021        );
1022
1023        // Blank role never matches anything, so it is unrestricted (absent),
1024        // not restricted-to-nothing.
1025        assert!(!allowlist.is_restricted(Some("   ")));
1026
1027        // The LATER "researcher" entry (fetch_url, blank dropped) won, not the
1028        // earlier one (nova_click) — and the blank tool name was dropped.
1029        assert!(allowlist.allows(Some("researcher"), "fetch_url"));
1030        assert!(!allowlist.allows(Some("researcher"), "nova_click"));
1031
1032        // A tool name absent from `known_tools` (a likely typo) is still
1033        // enforced — presence in `known_tools` is a validation SIGNAL
1034        // (warned), not a hard filter.
1035        assert!(allowlist.allows(Some("typo-role"), "fetch_urll"));
1036        assert!(!allowlist.allows(Some("typo-role"), "fetch_url"));
1037    }
1038
1039    /// `from_config` with an EMPTY `known_tools` set (e.g. the backend's tool
1040    /// list is not available yet at config-load time) skips tool-name
1041    /// validation entirely rather than flagging every configured tool as
1042    /// unknown — the allowlist itself is unaffected either way.
1043    #[test]
1044    fn from_config_skips_tool_validation_when_known_tools_is_empty() {
1045        let allowlist = RoleToolAllowlist::from_config(
1046            vec![("researcher", vec!["anything_at_all"])],
1047            &HashSet::new(),
1048        );
1049        assert!(allowlist.allows(Some("researcher"), "anything_at_all"));
1050        assert!(!allowlist.allows(Some("researcher"), "other_tool"));
1051    }
1052
1053    /// A role mapped to an EMPTY allowlist is an explicit lockout (no tools),
1054    /// distinct from an absent role (unrestricted).
1055    #[tokio::test]
1056    async fn empty_allowlist_entry_is_explicit_lockout() {
1057        let backend = MultiToolMcp;
1058        let allowlist = RoleToolAllowlist::from_entries(vec![("sandbox", Vec::<String>::new())]);
1059        let reply = handle_mcp_request(
1060            &backend,
1061            &allowlist,
1062            worker_request(Some("sandbox"), McpRequest::Manifest),
1063        )
1064        .await;
1065        assert!(manifest_names(&reply).is_empty());
1066    }
1067
1068    #[tokio::test]
1069    async fn proxy_lists_and_forwards_calls_over_the_broker() {
1070        let dir = tempfile::tempdir().unwrap();
1071        let core = Arc::new(BrokerCore::new(dir.path()));
1072        let server = Arc::new(BrokerServer::new(core, TOKEN));
1073        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1074        let addr = listener.local_addr().unwrap();
1075        tokio::spawn(async move {
1076            let _ = server.serve(listener).await;
1077        });
1078        let endpoint = format!("ws://{addr}");
1079
1080        // Orchestrator runs the proxy service backed by the stub host-bound MCP.
1081        let ep = endpoint.clone();
1082        tokio::spawn(async move {
1083            let _ = serve_mcp_proxy(
1084                &ep,
1085                AgentRef {
1086                    session_id: "orchestrator".into(),
1087                    role: None,
1088                },
1089                TOKEN,
1090                Arc::new(StubMcp),
1091                Arc::new(RoleToolAllowlist::unrestricted()),
1092            )
1093            .await;
1094        });
1095
1096        // Worker builds a proxy: it fetches the manifest and advertises the tool.
1097        let proxy = McpProxyExecutor::connect(
1098            &endpoint,
1099            "worker#mcp",
1100            None,
1101            TOKEN,
1102            "orchestrator",
1103            Duration::from_secs(5),
1104        )
1105        .await
1106        .expect("proxy connects + fetches manifest");
1107        let tools = proxy.list_tools();
1108        assert_eq!(tools.len(), 1);
1109        assert_eq!(tools[0].function.name, "nova_click");
1110        assert!(proxy.owns_exact_tool("nova_click"));
1111        assert!(!proxy.owns_exact_tool("NOVA_CLICK"));
1112        assert!(!proxy.owns_exact_tool("default::nova_click"));
1113
1114        // A call is forwarded to the orchestrator and the result comes back.
1115        let call = ToolCall {
1116            id: "c1".into(),
1117            tool_type: "function".into(),
1118            function: FunctionCall {
1119                name: "nova_click".into(),
1120                arguments: "{\"mark\":7}".into(),
1121            },
1122        };
1123        let result = proxy.execute(&call).await.expect("proxied call returns");
1124        assert!(result.success);
1125        assert_eq!(result.result, "ran nova_click args={\"mark\":7}");
1126
1127        // Unknown tools are not handled by the proxy.
1128        let miss = ToolCall {
1129            id: "c2".into(),
1130            tool_type: "function".into(),
1131            function: FunctionCall {
1132                name: "not_proxied".into(),
1133                arguments: "{}".into(),
1134            },
1135        };
1136        assert!(matches!(
1137            proxy.execute(&miss).await,
1138            Err(ToolError::NotFound(_))
1139        ));
1140    }
1141
1142    /// Issue #54, REAL path: drives `RoleToolAllowlist` filtering through the
1143    /// ACTUAL production wiring — a real broker, `serve_mcp_proxy` reading the
1144    /// role off the connecting worker's `AgentRef` (not `handle_mcp_request`
1145    /// called directly with an explicit allowlist), and `McpProxyExecutor::
1146    /// connect` advertising the worker's real role. Exercising only
1147    /// `handle_mcp_request` (as the other unit tests in this module do) would
1148    /// mask the exact bug #54 was reopened for: the role never reaching the
1149    /// orchestrator-side filter in production because nothing threaded it
1150    /// through the real connect/serve path.
1151    #[tokio::test]
1152    async fn proxy_executor_role_is_filtered_end_to_end_over_the_real_broker() {
1153        let dir = tempfile::tempdir().unwrap();
1154        let core = Arc::new(BrokerCore::new(dir.path()));
1155        let server = Arc::new(BrokerServer::new(core, TOKEN));
1156        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1157        let addr = listener.local_addr().unwrap();
1158        tokio::spawn(async move {
1159            let _ = server.serve(listener).await;
1160        });
1161        let endpoint = format!("ws://{addr}");
1162
1163        // Orchestrator: "researcher" may only proxy the benign fetch tool.
1164        let ep = endpoint.clone();
1165        tokio::spawn(async move {
1166            let _ = serve_mcp_proxy(
1167                &ep,
1168                AgentRef {
1169                    session_id: "orchestrator".into(),
1170                    role: None,
1171                },
1172                TOKEN,
1173                Arc::new(MultiToolMcp),
1174                Arc::new(RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"])),
1175            )
1176            .await;
1177        });
1178
1179        // A worker connecting AS "researcher" — over the real broker, with its
1180        // role threaded through `McpProxyExecutor::connect` exactly as
1181        // `subagent_worker.rs` does in production — must see only its role's
1182        // allowed tool in the manifest it fetches on connect.
1183        let restricted = McpProxyExecutor::connect(
1184            &endpoint,
1185            "worker-researcher#mcp",
1186            Some("researcher".to_string()),
1187            TOKEN,
1188            "orchestrator",
1189            Duration::from_secs(5),
1190        )
1191        .await
1192        .expect("restricted proxy connects + fetches its role-scoped manifest");
1193        let tools = restricted.list_tools();
1194        assert_eq!(
1195            tools
1196                .iter()
1197                .map(|t| t.function.name.clone())
1198                .collect::<Vec<_>>(),
1199            vec!["fetch_url".to_string()],
1200            "a role WITH an allowlist entry must see only its allowed tools over the real path"
1201        );
1202
1203        // The allowed tool executes normally...
1204        let ok_call = ToolCall {
1205            id: "c1".into(),
1206            tool_type: "function".into(),
1207            function: FunctionCall {
1208                name: "fetch_url".into(),
1209                arguments: "{}".into(),
1210            },
1211        };
1212        let result = restricted
1213            .execute(&ok_call)
1214            .await
1215            .expect("allowed tool call succeeds");
1216        assert!(result.success);
1217
1218        // ...but a disallowed tool is invisible in the manifest, so the LOCAL
1219        // "not in my manifest" check rejects it before it is even sent —
1220        // proving the manifest filtering (not just the orchestrator's
1221        // defense-in-depth Call check) actually took effect end-to-end.
1222        let denied_call = ToolCall {
1223            id: "c2".into(),
1224            tool_type: "function".into(),
1225            function: FunctionCall {
1226                name: "nova_screenshot".into(),
1227                arguments: "{}".into(),
1228            },
1229        };
1230        assert!(matches!(
1231            restricted.execute(&denied_call).await,
1232            Err(ToolError::NotFound(_))
1233        ));
1234
1235        // A worker connecting with NO role (unrestricted, back-compat) still
1236        // sees the full tool set over the same real orchestrator/allowlist.
1237        let unrestricted = McpProxyExecutor::connect(
1238            &endpoint,
1239            "worker-unrestricted#mcp",
1240            None,
1241            TOKEN,
1242            "orchestrator",
1243            Duration::from_secs(5),
1244        )
1245        .await
1246        .expect("unrestricted proxy connects");
1247        assert_eq!(unrestricted.list_tools().len(), 3);
1248    }
1249
1250    #[tokio::test]
1251    async fn proxy_handles_concurrent_calls_correctly() {
1252        let dir = tempfile::tempdir().unwrap();
1253        let core = Arc::new(BrokerCore::new(dir.path()));
1254        let server = Arc::new(BrokerServer::new(core, TOKEN));
1255        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1256        let addr = listener.local_addr().unwrap();
1257        tokio::spawn(async move {
1258            let _ = server.serve(listener).await;
1259        });
1260        let endpoint = format!("ws://{addr}");
1261
1262        let ep = endpoint.clone();
1263        tokio::spawn(async move {
1264            let _ = serve_mcp_proxy(
1265                &ep,
1266                AgentRef {
1267                    session_id: "orchestrator".into(),
1268                    role: None,
1269                },
1270                TOKEN,
1271                Arc::new(StubMcp),
1272                Arc::new(RoleToolAllowlist::unrestricted()),
1273            )
1274            .await;
1275        });
1276
1277        let proxy = Arc::new(
1278            McpProxyExecutor::connect(
1279                &endpoint,
1280                "worker#mcp",
1281                None,
1282                TOKEN,
1283                "orchestrator",
1284                Duration::from_secs(5),
1285            )
1286            .await
1287            .expect("proxy connects"),
1288        );
1289
1290        // Fire N concurrent proxied calls with DISTINCT args over the multiplexed
1291        // connection (no per-call exclusive lock). Each must get its OWN result —
1292        // proving concurrent execute() doesn't serialize-deadlock or mis-route
1293        // replies. (End-to-end latency is still capped by the serial orchestrator
1294        // serve_mcp_proxy — that's the complementary half, tracked separately.) #56.
1295        let mut handles = Vec::new();
1296        for i in 0..8u32 {
1297            let p = proxy.clone();
1298            handles.push(tokio::spawn(async move {
1299                let call = ToolCall {
1300                    id: format!("c{i}"),
1301                    tool_type: "function".into(),
1302                    function: FunctionCall {
1303                        name: "nova_click".into(),
1304                        arguments: format!("{{\"mark\":{i}}}"),
1305                    },
1306                };
1307                let r = p.execute(&call).await.expect("proxied call returns");
1308                (i, r.result)
1309            }));
1310        }
1311        for h in handles {
1312            let (i, result) = h.await.unwrap();
1313            assert_eq!(result, format!("ran nova_click args={{\"mark\":{i}}}"));
1314        }
1315    }
1316
1317    #[tokio::test]
1318    async fn concurrent_proxy_calls_overlap_at_the_orchestrator() {
1319        // Prove overlap DIRECTLY (issue #486) instead of inferring it from
1320        // wall-clock duration: this originally asserted `elapsed < N ms`
1321        // against a `sleep(200ms)` backend, which raced CI load. Then #502
1322        // switched to a high-water-mark (`fetch_max` of instantaneous
1323        // `in_flight`), which is deterministic in the "genuinely serial"
1324        // direction but still racy in the "CPU-starved" direction: under
1325        // extreme scheduler pressure the 4 spawned calls might never all
1326        // actually be polled at once, so the observed high-water mark can
1327        // top out below 4 even though the orchestrator itself doesn't
1328        // serialize anything — a false failure with no code regression.
1329        //
1330        // Fix: force the rendezvous instead of hoping for it. `SlowMcp`
1331        // bumps `in_flight`/`max_in_flight` and then blocks each call on a
1332        // `Barrier` sized for all 4 concurrent calls — nobody proceeds until
1333        // all 4 have arrived. That makes `max_in_flight == 4` deterministic
1334        // regardless of how loaded the host is. If the orchestrator ever
1335        // regresses to serializing these calls, only 1 of the 4 barrier
1336        // parties will ever arrive and the wait deadlocks — bounded by the
1337        // outer `tokio::time::timeout` below, which turns that regression
1338        // into a clear, fast test failure instead of a hang.
1339        struct SlowMcp {
1340            in_flight: AtomicU32,
1341            max_in_flight: AtomicU32,
1342            rendezvous: tokio::sync::Barrier,
1343        }
1344        #[async_trait]
1345        impl ToolExecutor for SlowMcp {
1346            async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
1347                let now_in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1348                self.max_in_flight
1349                    .fetch_max(now_in_flight, Ordering::SeqCst);
1350                // Forced rendezvous: block until all 4 concurrent calls have
1351                // arrived here. This is what makes the overlap deterministic
1352                // rather than probabilistic.
1353                self.rendezvous.wait().await;
1354                tokio::time::sleep(Duration::from_millis(50)).await;
1355                self.in_flight.fetch_sub(1, Ordering::SeqCst);
1356                Ok(ToolResult {
1357                    success: true,
1358                    result: format!("done {}", call.function.arguments),
1359                    display_preference: None,
1360                    images: Vec::new(),
1361                })
1362            }
1363            async fn execute_with_context(
1364                &self,
1365                call: &ToolCall,
1366                _ctx: ToolExecutionContext<'_>,
1367            ) -> Result<ToolResult, ToolError> {
1368                self.execute(call).await
1369            }
1370            fn list_tools(&self) -> Vec<ToolSchema> {
1371                vec![ToolSchema {
1372                    schema_type: "function".into(),
1373                    function: FunctionSchema {
1374                        name: "nova_click".into(),
1375                        description: "click a mark".into(),
1376                        parameters: json!({ "type": "object" }),
1377                    },
1378                }]
1379            }
1380        }
1381        const N: u32 = 4;
1382        let slow_mcp = Arc::new(SlowMcp {
1383            in_flight: AtomicU32::new(0),
1384            max_in_flight: AtomicU32::new(0),
1385            rendezvous: tokio::sync::Barrier::new(N as usize),
1386        });
1387
1388        let dir = tempfile::tempdir().unwrap();
1389        let core = Arc::new(BrokerCore::new(dir.path()));
1390        let server = Arc::new(BrokerServer::new(core, TOKEN));
1391        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1392        let addr = listener.local_addr().unwrap();
1393        tokio::spawn(async move {
1394            let _ = server.serve(listener).await;
1395        });
1396        let endpoint = format!("ws://{addr}");
1397
1398        let ep = endpoint.clone();
1399        let mcp_for_server = slow_mcp.clone();
1400        tokio::spawn(async move {
1401            let _ = serve_mcp_proxy(
1402                &ep,
1403                AgentRef {
1404                    session_id: "orchestrator".into(),
1405                    role: None,
1406                },
1407                TOKEN,
1408                mcp_for_server,
1409                Arc::new(RoleToolAllowlist::unrestricted()),
1410            )
1411            .await;
1412        });
1413
1414        let proxy = Arc::new(
1415            McpProxyExecutor::connect(
1416                &endpoint,
1417                "worker#mcp",
1418                None,
1419                TOKEN,
1420                "orchestrator",
1421                Duration::from_secs(5),
1422            )
1423            .await
1424            .expect("proxy connects"),
1425        );
1426
1427        // N concurrent 50ms calls, forced to rendezvous inside `SlowMcp`;
1428        // `max_in_flight` records the high-water mark of calls it observed
1429        // simultaneously in progress.
1430        let mut handles = Vec::new();
1431        for i in 0..N {
1432            let p = proxy.clone();
1433            handles.push(tokio::spawn(async move {
1434                let call = ToolCall {
1435                    id: format!("c{i}"),
1436                    tool_type: "function".into(),
1437                    function: FunctionCall {
1438                        name: "nova_click".into(),
1439                        arguments: format!("{{\"i\":{i}}}"),
1440                    },
1441                };
1442                p.execute(&call).await.expect("returns")
1443            }));
1444        }
1445        tokio::time::timeout(Duration::from_secs(20), async {
1446            for h in handles {
1447                h.await.unwrap();
1448            }
1449        })
1450        .await
1451        .expect(
1452            "timed out waiting for the 4 concurrent proxy calls to complete — this means \
1453             the orchestrator is serializing them (only some of the 4 ever reached the \
1454             rendezvous barrier), which is the regression this test guards against",
1455        );
1456        let max_in_flight = slow_mcp.max_in_flight.load(Ordering::SeqCst);
1457        assert_eq!(
1458            max_in_flight, N,
1459            "{N} concurrent proxy calls must OVERLAP at the orchestrator (serial handling \
1460             could never observe more than 1 in flight at once); observed max_in_flight = \
1461             {max_in_flight}"
1462        );
1463    }
1464
1465    // --- issue #47: supervised reconnect on both sides ------------------------
1466
1467    /// Orchestrator supervisor: it restarts `serve_once` after each return with
1468    /// bounded backoff, and stops cleanly on shutdown. Driven with a stub
1469    /// `serve_once` (no real broker) and tiny backoff constants so it is fast
1470    /// and deterministic.
1471    #[tokio::test]
1472    async fn supervisor_restarts_on_drop_and_stops_on_shutdown() {
1473        let shutdown = CancellationToken::new();
1474        let calls = Arc::new(AtomicU32::new(0));
1475        let calls_for_serve = calls.clone();
1476        // serve_once: succeed quickly 3 times (quick restarts), then block
1477        // forever — a "healthy", long-lived connection that only ends on cancel.
1478        let serve = move || {
1479            let c = calls_for_serve.clone();
1480            async move {
1481                let n = c.fetch_add(1, Ordering::SeqCst);
1482                if n < 3 {
1483                    Ok(())
1484                } else {
1485                    std::future::pending::<BrokerResult<()>>().await
1486                }
1487            }
1488        };
1489
1490        let started = std::time::Instant::now();
1491        let task = tokio::spawn(supervise_reconnect(
1492            serve,
1493            shutdown.clone(),
1494            Duration::from_millis(2),
1495            Duration::from_millis(8),
1496            Duration::from_secs(60), // runs are instant → never "healthy", backoff grows
1497        ));
1498
1499        // 4 serve calls = 3 quick restarts + 1 blocking run, all within the
1500        // bounded backoff window (not e.g. 30s — proves the backoff is bounded).
1501        tokio::time::timeout(Duration::from_secs(3), async {
1502            while calls.load(Ordering::SeqCst) < 4 {
1503                tokio::task::yield_now().await;
1504            }
1505        })
1506        .await
1507        .expect("supervisor restarted within bounded backoff");
1508        assert!(calls.load(Ordering::SeqCst) >= 4);
1509        assert!(
1510            started.elapsed() < Duration::from_secs(3),
1511            "restarts were bounded-fast, took {:?}",
1512            started.elapsed()
1513        );
1514
1515        // Shutdown interrupts the blocking serve (+ any backoff) and ends the loop.
1516        shutdown.cancel();
1517        tokio::time::timeout(Duration::from_secs(3), task)
1518            .await
1519            .expect("supervisor stops promptly on shutdown")
1520            .expect("supervisor task did not panic");
1521    }
1522
1523    /// Build the correlated `McpReply` for one worker `McpRequest` (used by the
1524    /// stand-in broker below).
1525    fn answer_mcp_request(req_msg: InboxMessage, orch: &AgentRef) -> InboxMessage {
1526        let reply_body = match serde_json::from_value::<McpRequest>(req_msg.body) {
1527            Ok(McpRequest::Manifest) => McpReply {
1528                manifest: Some(vec![ToolSchema {
1529                    schema_type: "function".into(),
1530                    function: FunctionSchema {
1531                        name: "nova_click".into(),
1532                        description: "click a mark".into(),
1533                        parameters: json!({ "type": "object" }),
1534                    },
1535                }]),
1536                ..Default::default()
1537            },
1538            Ok(McpRequest::Call { tool, arguments }) => McpReply {
1539                result: Some(ProxiedResult {
1540                    success: true,
1541                    result: format!("ran {tool} args={arguments}"),
1542                }),
1543                ..Default::default()
1544            },
1545            Err(_) => McpReply {
1546                error: Some("bad mcp request".into()),
1547                ..Default::default()
1548            },
1549        };
1550        InboxMessage {
1551            id: MsgId::new(),
1552            from: orch.clone(),
1553            kind: InboxKind::McpReply,
1554            body: serde_json::to_value(reply_body).unwrap_or_default(),
1555            created_at: Utc::now(),
1556            correlation_id: Some(req_msg.id),
1557        }
1558    }
1559
1560    /// A stand-in broker that ALSO answers `McpRequest`s as the orchestrator
1561    /// would (the worker can't tell the difference — it just needs correlated
1562    /// replies). The FIRST accepted connection serves the connect `Manifest` +
1563    /// one `Call`, then closes the socket — simulating a transient WebSocket
1564    /// drop. Subsequent connections (reconnects) stay open and keep answering.
1565    /// Returns (endpoint, connections-accepted-counter).
1566    async fn flaky_mcp_broker() -> (String, Arc<AtomicU32>) {
1567        use futures_util::{SinkExt, StreamExt};
1568        use tokio_tungstenite::accept_async;
1569        use tokio_tungstenite::tungstenite::Message;
1570
1571        use crate::proto::{BrokerFrame, ClientFrame};
1572
1573        let orch = AgentRef {
1574            session_id: "orchestrator".into(),
1575            role: None,
1576        };
1577        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1578        let addr = listener.local_addr().unwrap();
1579        let first_taken = Arc::new(AtomicBool::new(false));
1580        let conns = Arc::new(AtomicU32::new(0));
1581        let conns_for_loop = conns.clone();
1582        tokio::spawn(async move {
1583            loop {
1584                let (stream, _) = match listener.accept().await {
1585                    Ok(s) => s,
1586                    Err(_) => break,
1587                };
1588                let is_first = !first_taken.swap(true, Ordering::SeqCst);
1589                conns_for_loop.fetch_add(1, Ordering::SeqCst);
1590                let orch = orch.clone();
1591                tokio::spawn(async move {
1592                    let ws = match accept_async(stream).await {
1593                        Ok(ws) => ws,
1594                        Err(_) => return,
1595                    };
1596                    let (mut sink, mut source) = ws.split();
1597
1598                    // 1. Hello → Welcome.
1599                    while let Some(Ok(msg)) = source.next().await {
1600                        if let Message::Text(t) = msg {
1601                            if ClientFrame::from_text(&t).is_ok() {
1602                                let _ = sink
1603                                    .send(Message::text(BrokerFrame::Welcome.to_text()))
1604                                    .await;
1605                                break;
1606                            }
1607                        }
1608                    }
1609
1610                    // 2. Serve Deliver and durable ACK frames. The first
1611                    //    connection closes after the connect Manifest + one
1612                    //    Call have both been answered and ACKed. Reconnects
1613                    //    stay open.
1614                    let mut delivered = 0u32;
1615                    let mut unacked_replies = HashSet::new();
1616                    let mut close_after_ack = None;
1617                    while let Some(Ok(msg)) = source.next().await {
1618                        let frame = match msg {
1619                            Message::Text(t) => match ClientFrame::from_text(&t) {
1620                                Ok(frame) => frame,
1621                                _ => continue,
1622                            },
1623                            _ => continue,
1624                        };
1625                        match frame {
1626                            ClientFrame::Deliver { message, .. } => {
1627                                let _ = sink
1628                                    .send(Message::text(
1629                                        BrokerFrame::Delivered {
1630                                            id: message.id.clone(),
1631                                        }
1632                                        .to_text(),
1633                                    ))
1634                                    .await;
1635                                let reply = answer_mcp_request(message, &orch);
1636                                unacked_replies.insert(reply.id.clone());
1637                                if is_first {
1638                                    delivered += 1;
1639                                    if delivered == 2 {
1640                                        close_after_ack = Some(reply.id.clone());
1641                                    }
1642                                }
1643                                let _ = sink
1644                                    .send(Message::text(
1645                                        BrokerFrame::Message { message: reply }.to_text(),
1646                                    ))
1647                                    .await;
1648                            }
1649                            ClientFrame::AckWithReceipt { id, request_id } => {
1650                                let accepted = unacked_replies.remove(&id);
1651                                let _ = sink
1652                                    .send(Message::text(
1653                                        BrokerFrame::AckResult {
1654                                            id: id.clone(),
1655                                            request_id,
1656                                            accepted,
1657                                            reason: (!accepted).then(|| "unknown reply".into()),
1658                                        }
1659                                        .to_text(),
1660                                    ))
1661                                    .await;
1662                                if accepted && close_after_ack.as_ref() == Some(&id) {
1663                                    let _ = sink.send(Message::Close(None)).await;
1664                                    break;
1665                                }
1666                            }
1667                            _ => {}
1668                        }
1669                    }
1670                });
1671            }
1672        });
1673        (format!("ws://{addr}"), conns)
1674    }
1675
1676    /// Worker reconnect (issue #47): after the broker connection drops mid-run,
1677    /// a later call transparently reconnects and succeeds instead of surfacing a
1678    /// permanent transport error.
1679    #[tokio::test]
1680    async fn proxy_executor_reconnects_after_transient_drop() {
1681        let (endpoint, conns) = flaky_mcp_broker().await;
1682
1683        let proxy = McpProxyExecutor::connect(
1684            &endpoint,
1685            "worker#mcp",
1686            None,
1687            TOKEN,
1688            "orchestrator",
1689            Duration::from_secs(5),
1690        )
1691        .await
1692        .expect("proxy connects + fetches manifest");
1693        assert_eq!(proxy.list_tools().len(), 1);
1694
1695        let call = |n: usize| ToolCall {
1696            id: format!("c{n}"),
1697            tool_type: "function".into(),
1698            function: FunctionCall {
1699                name: "nova_click".into(),
1700                arguments: "{}".into(),
1701            },
1702        };
1703
1704        // conn1: the first call succeeds before the (simulated) drop.
1705        let r1 = proxy.execute(&call(1)).await.expect("call1 on conn1");
1706        assert!(r1.success);
1707
1708        // conn1 is now closed. This call must lazily reconnect (conn2) and retry
1709        // — NOT return a permanent "connection closed before reply" error.
1710        let r2 = tokio::time::timeout(Duration::from_secs(15), proxy.execute(&call(2)))
1711            .await
1712            .expect("call2 did not hang")
1713            .expect("call2 succeeds after reconnect (not a permanent error)");
1714        assert!(r2.success);
1715        assert_eq!(r2.result, "ran nova_click args={}");
1716
1717        // The reconnect opened a second broker connection.
1718        assert!(
1719            conns.load(Ordering::SeqCst) >= 2,
1720            "worker reconnected (>=2 connections accepted), got {}",
1721            conns.load(Ordering::SeqCst)
1722        );
1723
1724        // A third call on the (now healthy) reconnected connection succeeds too,
1725        // proving the executor is not permanently disabled by the drop.
1726        let r3 = proxy.execute(&call(3)).await.expect("call3 on conn2");
1727        assert!(r3.success);
1728    }
1729}