use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use async_trait::async_trait;
use bamboo_agent_core::tools::{
FunctionCall, ToolCall, ToolError, ToolExecutionContext, ToolExecutor, ToolResult, ToolSchema,
};
use bamboo_subagent::{AgentRef, InboxKind, InboxMessage, MsgId};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::client::BrokerClient;
use crate::error::{BrokerError, BrokerResult};
use crate::mux::MultiplexedClient;
const PROXY_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(500);
const PROXY_RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(30);
const PROXY_BACKOFF_RESET_AFTER: Duration = Duration::from_secs(10);
const WORKER_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const WORKER_RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(2);
const WORKER_RECONNECT_MAX_ATTEMPTS: u32 = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum McpRequest {
Manifest,
Call { tool: String, arguments: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpReply {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest: Option<Vec<ToolSchema>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<ProxiedResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxiedResult {
pub success: bool,
pub result: String,
}
#[derive(Debug, Clone, Default)]
pub struct RoleToolAllowlist {
by_role: HashMap<String, HashSet<String>>,
}
impl RoleToolAllowlist {
pub fn unrestricted() -> Self {
Self::default()
}
pub fn from_entries<R, T, I>(entries: I) -> Self
where
R: Into<String>,
T: Into<String>,
I: IntoIterator<Item = (R, Vec<T>)>,
{
let by_role = entries
.into_iter()
.map(|(role, tools)| (role.into(), tools.into_iter().map(Into::into).collect()))
.collect();
Self { by_role }
}
pub fn with_role(
mut self,
role: impl Into<String>,
tools: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.by_role
.insert(role.into(), tools.into_iter().map(Into::into).collect());
self
}
pub fn from_config<R, T, I>(entries: I, known_tools: &HashSet<String>) -> Self
where
R: Into<String>,
T: Into<String>,
I: IntoIterator<Item = (R, Vec<T>)>,
{
let mut by_role: HashMap<String, HashSet<String>> = HashMap::new();
for (role, tools) in entries {
let role: String = role.into().trim().to_string();
if role.is_empty() {
tracing::warn!(
"mcp role allowlist config: skipping an entry with a blank role name \
(it could never match a real worker's role)"
);
continue;
}
if by_role.contains_key(&role) {
tracing::warn!(
role = %role,
"mcp role allowlist config: duplicate entry for this role; \
the later entry replaces the earlier one"
);
}
let tool_set: HashSet<String> = tools
.into_iter()
.map(Into::into)
.filter_map(|t| {
let t = t.trim().to_string();
if t.is_empty() {
tracing::warn!(
role = %role,
"mcp role allowlist config: skipping a blank tool name"
);
None
} else {
Some(t)
}
})
.collect();
if !known_tools.is_empty() {
for tool in &tool_set {
if !known_tools.contains(tool) {
tracing::warn!(
role = %role,
tool = %tool,
"mcp role allowlist config: tool name is not in the orchestrator's \
current MCP tool set (check for a typo) — the entry is still \
enforced, so a mistyped name silently grants nothing for it"
);
}
}
}
by_role.insert(role, tool_set);
}
Self { by_role }
}
fn is_restricted(&self, role: Option<&str>) -> bool {
role.is_some_and(|r| self.by_role.contains_key(r))
}
fn allows(&self, role: Option<&str>, tool: &str) -> bool {
match role.and_then(|r| self.by_role.get(r)) {
Some(allowed) => allowed.contains(tool),
None => true, }
}
fn filter_manifest(&self, role: Option<&str>, mut tools: Vec<ToolSchema>) -> Vec<ToolSchema> {
if let Some(allowed) = role.and_then(|r| self.by_role.get(r)) {
tools.retain(|t| allowed.contains(&t.function.name));
}
tools
}
}
pub async fn serve_mcp_proxy(
endpoint: &str,
me: AgentRef,
token: &str,
backend: Arc<dyn ToolExecutor>,
allowlist: Arc<RoleToolAllowlist>,
) -> BrokerResult<()> {
let mut client = BrokerClient::connect(endpoint, me.clone(), token).await?;
client.subscribe().await?;
let (reply_tx, mut reply_rx) =
tokio::sync::mpsc::unbounded_channel::<(MsgId, String, McpReply)>();
loop {
tokio::select! {
Some((corr, reply_to, reply_body)) = reply_rx.recv() => {
let reply = InboxMessage {
id: MsgId::new(),
from: me.clone(),
kind: InboxKind::McpReply,
body: serde_json::to_value(reply_body).unwrap_or_default(),
created_at: Utc::now(),
correlation_id: Some(corr.clone()),
};
client.deliver(&reply_to, reply).await?;
client.ack(corr).await?;
}
msg = client.next_message() => {
let Some(msg) = msg else { break }; if msg.kind != InboxKind::McpRequest {
let _ = client.ack(msg.id).await;
continue;
}
let backend = Arc::clone(&backend);
let allowlist = Arc::clone(&allowlist);
let reply_tx = reply_tx.clone();
let corr = msg.id.clone();
let reply_to = msg.from.session_id.clone();
tokio::spawn(async move {
let reply_body = handle_mcp_request(backend.as_ref(), &allowlist, msg).await;
let _ = reply_tx.send((corr, reply_to, reply_body));
});
}
}
}
Ok(())
}
pub async fn serve_mcp_proxy_supervised(
endpoint: &str,
me: AgentRef,
token: &str,
backend: Arc<dyn ToolExecutor>,
allowlist: Arc<RoleToolAllowlist>,
shutdown: CancellationToken,
) {
supervise_reconnect(
|| {
serve_mcp_proxy(
endpoint,
me.clone(),
token,
backend.clone(),
allowlist.clone(),
)
},
shutdown,
PROXY_RECONNECT_INITIAL_BACKOFF,
PROXY_RECONNECT_MAX_BACKOFF,
PROXY_BACKOFF_RESET_AFTER,
)
.await
}
async fn supervise_reconnect<F, Fut>(
mut serve_once: F,
shutdown: CancellationToken,
initial_backoff: Duration,
max_backoff: Duration,
reset_after: Duration,
) where
F: FnMut() -> Fut + Send,
Fut: Future<Output = BrokerResult<()>> + Send,
{
let mut backoff = initial_backoff;
loop {
let started = std::time::Instant::now();
let outcome = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("MCP proxy supervisor: shutdown requested, stopping");
return;
}
r = serve_once() => r,
};
if started.elapsed() >= reset_after {
backoff = initial_backoff;
}
match outcome {
Ok(()) => tracing::warn!(
"MCP proxy connection ended; restarting (backoff {:?})",
backoff
),
Err(e) => tracing::warn!(
"MCP proxy service errored: {e}; restarting (backoff {:?})",
backoff
),
}
let slept = tokio::select! {
biased;
_ = shutdown.cancelled() => false,
_ = tokio::time::sleep(backoff) => true,
};
if !slept {
tracing::info!("MCP proxy supervisor: shutdown during backoff, stopping");
return;
}
backoff = std::cmp::min(backoff * 2, max_backoff);
}
}
async fn handle_mcp_request(
backend: &dyn ToolExecutor,
allowlist: &RoleToolAllowlist,
msg: InboxMessage,
) -> McpReply {
let role = msg.from.role.as_deref();
match serde_json::from_value::<McpRequest>(msg.body) {
Ok(McpRequest::Manifest) => {
let tools = allowlist.filter_manifest(role, backend.list_tools());
if allowlist.is_restricted(role) {
tracing::debug!(
role = role.unwrap_or("<none>"),
tools = tools.len(),
"mcp proxy: serving role-scoped manifest"
);
}
McpReply {
manifest: Some(tools),
..Default::default()
}
}
Ok(McpRequest::Call { tool, arguments }) => {
if !allowlist.allows(role, &tool) {
tracing::warn!(
role = role.unwrap_or("<none>"),
tool = %tool,
"mcp proxy: rejecting tool call not on role allowlist"
);
return McpReply {
error: Some(format!(
"tool '{tool}' is not allowed for role '{}'",
role.unwrap_or("<none>")
)),
..Default::default()
};
}
let call = ToolCall {
id: format!("mcp-{}", MsgId::new().as_str()),
tool_type: "function".to_string(),
function: FunctionCall {
name: tool,
arguments,
},
};
match backend.execute(&call).await {
Ok(r) => McpReply {
result: Some(ProxiedResult {
success: r.success,
result: r.result,
}),
..Default::default()
},
Err(e) => McpReply {
error: Some(e.to_string()),
..Default::default()
},
}
}
Err(e) => McpReply {
error: Some(format!("bad mcp request: {e}")),
..Default::default()
},
}
}
pub struct McpProxyExecutor {
client: tokio::sync::RwLock<Arc<MultiplexedClient>>,
reconnect_lock: Mutex<()>,
me: AgentRef,
endpoint: String,
token: String,
orchestrator: String,
manifest: RwLock<Vec<ToolSchema>>,
timeout: Duration,
}
impl McpProxyExecutor {
pub async fn connect(
endpoint: &str,
proxy_id: impl Into<String>,
role: Option<String>,
token: &str,
orchestrator: impl Into<String>,
timeout: Duration,
) -> BrokerResult<Self> {
let me = AgentRef {
session_id: proxy_id.into(),
role,
};
let orchestrator = orchestrator.into();
let mut client = BrokerClient::connect(endpoint, me.clone(), token).await?;
client.subscribe().await?;
let mux = client.into_multiplexed(me.clone());
let reply = mux
.request(
&orchestrator,
InboxKind::McpRequest,
serde_json::to_value(McpRequest::Manifest).expect("McpRequest serializes"),
timeout,
)
.await?;
let reply: McpReply = serde_json::from_value(reply)
.map_err(|e| BrokerError::Protocol(format!("bad manifest reply: {e}")))?;
let manifest = reply.manifest.unwrap_or_default();
Ok(Self {
client: tokio::sync::RwLock::new(Arc::new(mux)),
reconnect_lock: Mutex::new(()),
me,
endpoint: endpoint.to_string(),
token: token.to_string(),
orchestrator,
manifest: RwLock::new(manifest),
timeout,
})
}
pub fn tool_count(&self) -> usize {
self.manifest.read().map(|m| m.len()).unwrap_or(0)
}
async fn request_once(&self, body: serde_json::Value) -> BrokerResult<serde_json::Value> {
let mux = self.client.read().await.clone();
mux.request(
&self.orchestrator,
InboxKind::McpRequest,
body,
self.timeout,
)
.await
}
async fn request_with_reconnect(
&self,
body: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
match self.request_once(body.clone()).await {
Ok(v) => Ok(v),
Err(first) => {
if !self.connection_broken().await {
return Err(ToolError::Execution(format!("mcp proxy: {first}")));
}
tracing::warn!("mcp proxy connection dropped; reconnecting: {first}");
self.reconnect_if_needed()
.await
.map_err(|re| ToolError::Execution(format!("mcp proxy (reconnect): {re}")))?;
self.request_once(body)
.await
.map_err(|re| ToolError::Execution(format!("mcp proxy: {re}")))
}
}
}
async fn connection_broken(&self) -> bool {
!self.client.read().await.reader_alive()
}
async fn reconnect_if_needed(&self) -> BrokerResult<()> {
let _guard = self.reconnect_lock.lock().await;
if !self.connection_broken().await {
return Ok(());
}
let mut backoff = WORKER_RECONNECT_INITIAL_BACKOFF;
for _ in 0..WORKER_RECONNECT_MAX_ATTEMPTS {
match self.reconnect_once().await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!("mcp proxy reconnect failed (backoff {:?}): {e}", backoff);
}
}
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, WORKER_RECONNECT_MAX_BACKOFF);
}
Err(BrokerError::Transport(
"mcp proxy reconnect attempts exhausted".into(),
))
}
async fn reconnect_once(&self) -> BrokerResult<()> {
let mut client =
BrokerClient::connect(&self.endpoint, self.me.clone(), &self.token).await?;
client.subscribe().await?;
let mux = client.into_multiplexed(self.me.clone());
let reply = mux
.request(
&self.orchestrator,
InboxKind::McpRequest,
serde_json::to_value(McpRequest::Manifest).expect("McpRequest serializes"),
self.timeout,
)
.await?;
let reply: McpReply = serde_json::from_value(reply)
.map_err(|e| BrokerError::Protocol(format!("bad manifest reply: {e}")))?;
let manifest = reply.manifest.unwrap_or_default();
{
let mut slot = self.client.write().await;
*slot = Arc::new(mux);
}
if let Ok(mut m) = self.manifest.write() {
*m = manifest;
}
Ok(())
}
}
#[async_trait]
impl ToolExecutor for McpProxyExecutor {
async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
{
let manifest = self
.manifest
.read()
.map_err(|_| ToolError::Execution("mcp proxy manifest lock poisoned".into()))?;
if !manifest
.iter()
.any(|s| s.function.name == call.function.name)
{
return Err(ToolError::NotFound(call.function.name.clone()));
}
}
let body = serde_json::to_value(McpRequest::Call {
tool: call.function.name.clone(),
arguments: call.function.arguments.clone(),
})
.expect("McpRequest serializes");
let reply = self.request_with_reconnect(body).await?;
let reply: McpReply = serde_json::from_value(reply)
.map_err(|e| ToolError::Execution(format!("bad mcp reply: {e}")))?;
if let Some(err) = reply.error {
return Err(ToolError::Execution(err));
}
let r = reply
.result
.ok_or_else(|| ToolError::Execution("mcp reply missing result".to_string()))?;
Ok(ToolResult {
success: r.success,
result: r.result,
display_preference: None,
images: Vec::new(),
})
}
async fn execute_with_context(
&self,
call: &ToolCall,
_ctx: ToolExecutionContext<'_>,
) -> Result<ToolResult, ToolError> {
self.execute(call).await
}
fn list_tools(&self) -> Vec<ToolSchema> {
self.manifest.read().map(|m| m.clone()).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::BrokerCore;
use crate::server::BrokerServer;
use bamboo_agent_core::tools::FunctionSchema;
use serde_json::json;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use tokio::net::TcpListener;
const TOKEN: &str = "t";
struct StubMcp;
#[async_trait]
impl ToolExecutor for StubMcp {
async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
Ok(ToolResult {
success: true,
result: format!(
"ran {} args={}",
call.function.name, call.function.arguments
),
display_preference: None,
images: Vec::new(),
})
}
async fn execute_with_context(
&self,
call: &ToolCall,
_ctx: ToolExecutionContext<'_>,
) -> Result<ToolResult, ToolError> {
self.execute(call).await
}
fn list_tools(&self) -> Vec<ToolSchema> {
vec![ToolSchema {
schema_type: "function".into(),
function: FunctionSchema {
name: "nova_click".into(),
description: "click a mark".into(),
parameters: json!({ "type": "object" }),
},
}]
}
}
struct MultiToolMcp;
#[async_trait]
impl ToolExecutor for MultiToolMcp {
async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
Ok(ToolResult {
success: true,
result: format!("ran {}", call.function.name),
display_preference: None,
images: Vec::new(),
})
}
async fn execute_with_context(
&self,
call: &ToolCall,
_ctx: ToolExecutionContext<'_>,
) -> Result<ToolResult, ToolError> {
self.execute(call).await
}
fn list_tools(&self) -> Vec<ToolSchema> {
["nova_screenshot", "nova_click", "fetch_url"]
.into_iter()
.map(|name| ToolSchema {
schema_type: "function".into(),
function: FunctionSchema {
name: name.into(),
description: "t".into(),
parameters: json!({ "type": "object" }),
},
})
.collect()
}
}
fn worker_request(role: Option<&str>, req: McpRequest) -> InboxMessage {
InboxMessage {
id: MsgId::new(),
from: AgentRef {
session_id: "worker#mcp".into(),
role: role.map(Into::into),
},
kind: InboxKind::McpRequest,
body: serde_json::to_value(req).unwrap(),
created_at: Utc::now(),
correlation_id: None,
}
}
fn manifest_names(reply: &McpReply) -> Vec<String> {
reply
.manifest
.as_ref()
.expect("manifest reply")
.iter()
.map(|t| t.function.name.clone())
.collect()
}
#[tokio::test]
async fn manifest_is_filtered_by_role_allowlist() {
let backend = MultiToolMcp;
let allowlist = RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"]);
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(Some("researcher"), McpRequest::Manifest),
)
.await;
assert_eq!(manifest_names(&reply), vec!["fetch_url".to_string()]);
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(Some("operator"), McpRequest::Manifest),
)
.await;
assert_eq!(manifest_names(&reply).len(), 3);
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(None, McpRequest::Manifest),
)
.await;
assert_eq!(manifest_names(&reply).len(), 3);
}
#[tokio::test]
async fn call_is_rejected_when_tool_not_on_role_allowlist() {
let backend = MultiToolMcp;
let allowlist = RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"]);
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(
Some("researcher"),
McpRequest::Call {
tool: "nova_screenshot".into(),
arguments: "{}".into(),
},
),
)
.await;
assert!(reply.result.is_none());
let err = reply.error.expect("a rejection error");
assert!(
err.contains("nova_screenshot") && err.contains("not allowed"),
"{err}"
);
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(
Some("researcher"),
McpRequest::Call {
tool: "fetch_url".into(),
arguments: "{}".into(),
},
),
)
.await;
assert!(reply.error.is_none());
assert_eq!(reply.result.expect("result").result, "ran fetch_url");
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(
None,
McpRequest::Call {
tool: "nova_screenshot".into(),
arguments: "{}".into(),
},
),
)
.await;
assert!(reply.error.is_none());
assert_eq!(reply.result.expect("result").result, "ran nova_screenshot");
}
#[test]
fn from_config_validates_and_normalizes_entries() {
let known_tools: HashSet<String> = ["fetch_url", "nova_click"]
.into_iter()
.map(String::from)
.collect();
let allowlist = RoleToolAllowlist::from_config(
vec![
(" ".to_string(), vec!["fetch_url".to_string()]),
("researcher".to_string(), vec!["nova_click".to_string()]),
(
"researcher".to_string(),
vec!["fetch_url".to_string(), " ".to_string()],
),
("typo-role".to_string(), vec!["fetch_urll".to_string()]),
],
&known_tools,
);
assert!(!allowlist.is_restricted(Some(" ")));
assert!(allowlist.allows(Some("researcher"), "fetch_url"));
assert!(!allowlist.allows(Some("researcher"), "nova_click"));
assert!(allowlist.allows(Some("typo-role"), "fetch_urll"));
assert!(!allowlist.allows(Some("typo-role"), "fetch_url"));
}
#[test]
fn from_config_skips_tool_validation_when_known_tools_is_empty() {
let allowlist = RoleToolAllowlist::from_config(
vec![("researcher", vec!["anything_at_all"])],
&HashSet::new(),
);
assert!(allowlist.allows(Some("researcher"), "anything_at_all"));
assert!(!allowlist.allows(Some("researcher"), "other_tool"));
}
#[tokio::test]
async fn empty_allowlist_entry_is_explicit_lockout() {
let backend = MultiToolMcp;
let allowlist = RoleToolAllowlist::from_entries(vec![("sandbox", Vec::<String>::new())]);
let reply = handle_mcp_request(
&backend,
&allowlist,
worker_request(Some("sandbox"), McpRequest::Manifest),
)
.await;
assert!(manifest_names(&reply).is_empty());
}
#[tokio::test]
async fn proxy_lists_and_forwards_calls_over_the_broker() {
let dir = tempfile::tempdir().unwrap();
let core = Arc::new(BrokerCore::new(dir.path()));
let server = Arc::new(BrokerServer::new(core, TOKEN));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = server.serve(listener).await;
});
let endpoint = format!("ws://{addr}");
let ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_mcp_proxy(
&ep,
AgentRef {
session_id: "orchestrator".into(),
role: None,
},
TOKEN,
Arc::new(StubMcp),
Arc::new(RoleToolAllowlist::unrestricted()),
)
.await;
});
let proxy = McpProxyExecutor::connect(
&endpoint,
"worker#mcp",
None,
TOKEN,
"orchestrator",
Duration::from_secs(5),
)
.await
.expect("proxy connects + fetches manifest");
let tools = proxy.list_tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "nova_click");
let call = ToolCall {
id: "c1".into(),
tool_type: "function".into(),
function: FunctionCall {
name: "nova_click".into(),
arguments: "{\"mark\":7}".into(),
},
};
let result = proxy.execute(&call).await.expect("proxied call returns");
assert!(result.success);
assert_eq!(result.result, "ran nova_click args={\"mark\":7}");
let miss = ToolCall {
id: "c2".into(),
tool_type: "function".into(),
function: FunctionCall {
name: "not_proxied".into(),
arguments: "{}".into(),
},
};
assert!(matches!(
proxy.execute(&miss).await,
Err(ToolError::NotFound(_))
));
}
#[tokio::test]
async fn proxy_executor_role_is_filtered_end_to_end_over_the_real_broker() {
let dir = tempfile::tempdir().unwrap();
let core = Arc::new(BrokerCore::new(dir.path()));
let server = Arc::new(BrokerServer::new(core, TOKEN));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = server.serve(listener).await;
});
let endpoint = format!("ws://{addr}");
let ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_mcp_proxy(
&ep,
AgentRef {
session_id: "orchestrator".into(),
role: None,
},
TOKEN,
Arc::new(MultiToolMcp),
Arc::new(RoleToolAllowlist::unrestricted().with_role("researcher", ["fetch_url"])),
)
.await;
});
let restricted = McpProxyExecutor::connect(
&endpoint,
"worker-researcher#mcp",
Some("researcher".to_string()),
TOKEN,
"orchestrator",
Duration::from_secs(5),
)
.await
.expect("restricted proxy connects + fetches its role-scoped manifest");
let tools = restricted.list_tools();
assert_eq!(
tools
.iter()
.map(|t| t.function.name.clone())
.collect::<Vec<_>>(),
vec!["fetch_url".to_string()],
"a role WITH an allowlist entry must see only its allowed tools over the real path"
);
let ok_call = ToolCall {
id: "c1".into(),
tool_type: "function".into(),
function: FunctionCall {
name: "fetch_url".into(),
arguments: "{}".into(),
},
};
let result = restricted
.execute(&ok_call)
.await
.expect("allowed tool call succeeds");
assert!(result.success);
let denied_call = ToolCall {
id: "c2".into(),
tool_type: "function".into(),
function: FunctionCall {
name: "nova_screenshot".into(),
arguments: "{}".into(),
},
};
assert!(matches!(
restricted.execute(&denied_call).await,
Err(ToolError::NotFound(_))
));
let unrestricted = McpProxyExecutor::connect(
&endpoint,
"worker-unrestricted#mcp",
None,
TOKEN,
"orchestrator",
Duration::from_secs(5),
)
.await
.expect("unrestricted proxy connects");
assert_eq!(unrestricted.list_tools().len(), 3);
}
#[tokio::test]
async fn proxy_handles_concurrent_calls_correctly() {
let dir = tempfile::tempdir().unwrap();
let core = Arc::new(BrokerCore::new(dir.path()));
let server = Arc::new(BrokerServer::new(core, TOKEN));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = server.serve(listener).await;
});
let endpoint = format!("ws://{addr}");
let ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_mcp_proxy(
&ep,
AgentRef {
session_id: "orchestrator".into(),
role: None,
},
TOKEN,
Arc::new(StubMcp),
Arc::new(RoleToolAllowlist::unrestricted()),
)
.await;
});
let proxy = Arc::new(
McpProxyExecutor::connect(
&endpoint,
"worker#mcp",
None,
TOKEN,
"orchestrator",
Duration::from_secs(5),
)
.await
.expect("proxy connects"),
);
let mut handles = Vec::new();
for i in 0..8u32 {
let p = proxy.clone();
handles.push(tokio::spawn(async move {
let call = ToolCall {
id: format!("c{i}"),
tool_type: "function".into(),
function: FunctionCall {
name: "nova_click".into(),
arguments: format!("{{\"mark\":{i}}}"),
},
};
let r = p.execute(&call).await.expect("proxied call returns");
(i, r.result)
}));
}
for h in handles {
let (i, result) = h.await.unwrap();
assert_eq!(result, format!("ran nova_click args={{\"mark\":{i}}}"));
}
}
#[tokio::test]
async fn concurrent_proxy_calls_overlap_at_the_orchestrator() {
struct SlowMcp {
in_flight: AtomicU32,
max_in_flight: AtomicU32,
rendezvous: tokio::sync::Barrier,
}
#[async_trait]
impl ToolExecutor for SlowMcp {
async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
let now_in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
self.max_in_flight
.fetch_max(now_in_flight, Ordering::SeqCst);
self.rendezvous.wait().await;
tokio::time::sleep(Duration::from_millis(50)).await;
self.in_flight.fetch_sub(1, Ordering::SeqCst);
Ok(ToolResult {
success: true,
result: format!("done {}", call.function.arguments),
display_preference: None,
images: Vec::new(),
})
}
async fn execute_with_context(
&self,
call: &ToolCall,
_ctx: ToolExecutionContext<'_>,
) -> Result<ToolResult, ToolError> {
self.execute(call).await
}
fn list_tools(&self) -> Vec<ToolSchema> {
vec![ToolSchema {
schema_type: "function".into(),
function: FunctionSchema {
name: "nova_click".into(),
description: "click a mark".into(),
parameters: json!({ "type": "object" }),
},
}]
}
}
const N: u32 = 4;
let slow_mcp = Arc::new(SlowMcp {
in_flight: AtomicU32::new(0),
max_in_flight: AtomicU32::new(0),
rendezvous: tokio::sync::Barrier::new(N as usize),
});
let dir = tempfile::tempdir().unwrap();
let core = Arc::new(BrokerCore::new(dir.path()));
let server = Arc::new(BrokerServer::new(core, TOKEN));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = server.serve(listener).await;
});
let endpoint = format!("ws://{addr}");
let ep = endpoint.clone();
let mcp_for_server = slow_mcp.clone();
tokio::spawn(async move {
let _ = serve_mcp_proxy(
&ep,
AgentRef {
session_id: "orchestrator".into(),
role: None,
},
TOKEN,
mcp_for_server,
Arc::new(RoleToolAllowlist::unrestricted()),
)
.await;
});
let proxy = Arc::new(
McpProxyExecutor::connect(
&endpoint,
"worker#mcp",
None,
TOKEN,
"orchestrator",
Duration::from_secs(5),
)
.await
.expect("proxy connects"),
);
let mut handles = Vec::new();
for i in 0..N {
let p = proxy.clone();
handles.push(tokio::spawn(async move {
let call = ToolCall {
id: format!("c{i}"),
tool_type: "function".into(),
function: FunctionCall {
name: "nova_click".into(),
arguments: format!("{{\"i\":{i}}}"),
},
};
p.execute(&call).await.expect("returns")
}));
}
tokio::time::timeout(Duration::from_secs(20), async {
for h in handles {
h.await.unwrap();
}
})
.await
.expect(
"timed out waiting for the 4 concurrent proxy calls to complete — this means \
the orchestrator is serializing them (only some of the 4 ever reached the \
rendezvous barrier), which is the regression this test guards against",
);
let max_in_flight = slow_mcp.max_in_flight.load(Ordering::SeqCst);
assert_eq!(
max_in_flight, N,
"{N} concurrent proxy calls must OVERLAP at the orchestrator (serial handling \
could never observe more than 1 in flight at once); observed max_in_flight = \
{max_in_flight}"
);
}
#[tokio::test]
async fn supervisor_restarts_on_drop_and_stops_on_shutdown() {
let shutdown = CancellationToken::new();
let calls = Arc::new(AtomicU32::new(0));
let calls_for_serve = calls.clone();
let serve = move || {
let c = calls_for_serve.clone();
async move {
let n = c.fetch_add(1, Ordering::SeqCst);
if n < 3 {
Ok(())
} else {
std::future::pending::<BrokerResult<()>>().await
}
}
};
let started = std::time::Instant::now();
let task = tokio::spawn(supervise_reconnect(
serve,
shutdown.clone(),
Duration::from_millis(2),
Duration::from_millis(8),
Duration::from_secs(60), ));
tokio::time::timeout(Duration::from_secs(3), async {
while calls.load(Ordering::SeqCst) < 4 {
tokio::task::yield_now().await;
}
})
.await
.expect("supervisor restarted within bounded backoff");
assert!(calls.load(Ordering::SeqCst) >= 4);
assert!(
started.elapsed() < Duration::from_secs(3),
"restarts were bounded-fast, took {:?}",
started.elapsed()
);
shutdown.cancel();
tokio::time::timeout(Duration::from_secs(3), task)
.await
.expect("supervisor stops promptly on shutdown")
.expect("supervisor task did not panic");
}
fn answer_mcp_request(req_msg: InboxMessage, orch: &AgentRef) -> InboxMessage {
let reply_body = match serde_json::from_value::<McpRequest>(req_msg.body) {
Ok(McpRequest::Manifest) => McpReply {
manifest: Some(vec![ToolSchema {
schema_type: "function".into(),
function: FunctionSchema {
name: "nova_click".into(),
description: "click a mark".into(),
parameters: json!({ "type": "object" }),
},
}]),
..Default::default()
},
Ok(McpRequest::Call { tool, arguments }) => McpReply {
result: Some(ProxiedResult {
success: true,
result: format!("ran {tool} args={arguments}"),
}),
..Default::default()
},
Err(_) => McpReply {
error: Some("bad mcp request".into()),
..Default::default()
},
};
InboxMessage {
id: MsgId::new(),
from: orch.clone(),
kind: InboxKind::McpReply,
body: serde_json::to_value(reply_body).unwrap_or_default(),
created_at: Utc::now(),
correlation_id: Some(req_msg.id),
}
}
async fn flaky_mcp_broker() -> (String, Arc<AtomicU32>) {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use crate::proto::{BrokerFrame, ClientFrame};
let orch = AgentRef {
session_id: "orchestrator".into(),
role: None,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let first_taken = Arc::new(AtomicBool::new(false));
let conns = Arc::new(AtomicU32::new(0));
let conns_for_loop = conns.clone();
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => break,
};
let is_first = !first_taken.swap(true, Ordering::SeqCst);
conns_for_loop.fetch_add(1, Ordering::SeqCst);
let orch = orch.clone();
tokio::spawn(async move {
let ws = match accept_async(stream).await {
Ok(ws) => ws,
Err(_) => return,
};
let (mut sink, mut source) = ws.split();
while let Some(Ok(msg)) = source.next().await {
if let Message::Text(t) = msg {
if ClientFrame::from_text(&t).is_ok() {
let _ = sink
.send(Message::text(BrokerFrame::Welcome.to_text()))
.await;
break;
}
}
}
let mut delivered = 0u32;
while let Some(Ok(msg)) = source.next().await {
let message = match msg {
Message::Text(t) => match ClientFrame::from_text(&t) {
Ok(ClientFrame::Deliver { message, .. }) => message,
_ => continue,
},
_ => continue,
};
let _ = sink
.send(Message::text(
BrokerFrame::Delivered {
id: message.id.clone(),
}
.to_text(),
))
.await;
let reply = answer_mcp_request(message, &orch);
let _ = sink
.send(Message::text(
BrokerFrame::Message { message: reply }.to_text(),
))
.await;
if is_first {
delivered += 1;
if delivered == 2 {
let _ = sink.send(Message::Close(None)).await;
break;
}
}
}
});
}
});
(format!("ws://{addr}"), conns)
}
#[tokio::test]
async fn proxy_executor_reconnects_after_transient_drop() {
let (endpoint, conns) = flaky_mcp_broker().await;
let proxy = McpProxyExecutor::connect(
&endpoint,
"worker#mcp",
None,
TOKEN,
"orchestrator",
Duration::from_secs(5),
)
.await
.expect("proxy connects + fetches manifest");
assert_eq!(proxy.list_tools().len(), 1);
let call = |n: usize| ToolCall {
id: format!("c{n}"),
tool_type: "function".into(),
function: FunctionCall {
name: "nova_click".into(),
arguments: "{}".into(),
},
};
let r1 = proxy.execute(&call(1)).await.expect("call1 on conn1");
assert!(r1.success);
let r2 = tokio::time::timeout(Duration::from_secs(15), proxy.execute(&call(2)))
.await
.expect("call2 did not hang")
.expect("call2 succeeds after reconnect (not a permanent error)");
assert!(r2.success);
assert_eq!(r2.result, "ran nova_click args={}");
assert!(
conns.load(Ordering::SeqCst) >= 2,
"worker reconnected (>=2 connections accepted), got {}",
conns.load(Ordering::SeqCst)
);
let r3 = proxy.execute(&call(3)).await.expect("call3 on conn2");
assert!(r3.success);
}
}