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}