use std::sync::Arc;
use bob_core::{
error::ToolError,
ports::ToolPort,
types::{ToolCall, ToolDescriptor, ToolResult},
};
pub struct CompositeToolPort {
ports: Vec<(String, Arc<dyn ToolPort>)>,
}
impl std::fmt::Debug for CompositeToolPort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ids: Vec<&str> = self.ports.iter().map(|(id, _)| id.as_str()).collect();
f.debug_struct("CompositeToolPort").field("ports", &ids).finish()
}
}
impl CompositeToolPort {
#[must_use]
pub fn new(ports: Vec<(String, Arc<dyn ToolPort>)>) -> Self {
Self { ports }
}
}
#[async_trait::async_trait]
impl ToolPort for CompositeToolPort {
async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
let mut all = Vec::new();
for (_id, port) in &self.ports {
let tools = port.list_tools().await?;
all.extend(tools);
}
Ok(all)
}
async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
for (id, port) in &self.ports {
let prefix = format!("mcp/{id}/");
if call.name.starts_with(&prefix) {
return port.call_tool(call).await;
}
}
Err(ToolError::Execution(format!("no tool port owns tool '{}'", call.name)))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use bob_core::types::{ToolResult, ToolSource};
use super::*;
struct StubPort {
tools: Vec<ToolDescriptor>,
}
#[async_trait::async_trait]
impl ToolPort for StubPort {
async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
Ok(self.tools.clone())
}
async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
Ok(ToolResult {
name: call.name,
output: serde_json::json!({"ok": true}),
is_error: false,
})
}
}
#[tokio::test]
async fn lists_tools_from_all_ports() {
let p1 = Arc::new(StubPort {
tools: vec![
ToolDescriptor::new("mcp/fs/read_file", "Read a file")
.with_source(ToolSource::Mcp { server: "fs".into() }),
],
});
let p2 = Arc::new(StubPort {
tools: vec![
ToolDescriptor::new("mcp/git/log", "Git log")
.with_source(ToolSource::Mcp { server: "git".into() }),
],
});
let composite = CompositeToolPort::new(vec![
("fs".into(), p1 as Arc<dyn ToolPort>),
("git".into(), p2 as Arc<dyn ToolPort>),
]);
let tools = composite.list_tools().await.ok();
assert_eq!(tools.as_ref().map(Vec::len), Some(2));
}
#[tokio::test]
async fn routes_call_to_correct_port() {
let p1 = Arc::new(StubPort {
tools: vec![
ToolDescriptor::new("mcp/fs/read_file", "Read")
.with_source(ToolSource::Mcp { server: "fs".into() }),
],
});
let p2 = Arc::new(StubPort {
tools: vec![
ToolDescriptor::new("mcp/git/log", "Log")
.with_source(ToolSource::Mcp { server: "git".into() }),
],
});
let composite = CompositeToolPort::new(vec![
("fs".into(), p1 as Arc<dyn ToolPort>),
("git".into(), p2 as Arc<dyn ToolPort>),
]);
let call = ToolCall::new("mcp/git/log", serde_json::json!({}));
let result = composite.call_tool(call).await;
assert!(result.is_ok());
assert_eq!(result.ok().map(|r| r.name), Some("mcp/git/log".into()));
}
#[tokio::test]
async fn unknown_tool_returns_error() {
let composite = CompositeToolPort::new(vec![]);
let call = ToolCall::new("mcp/unknown/tool", serde_json::json!({}));
let result = composite.call_tool(call).await;
assert!(result.is_err());
}
}