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}