1use std::sync::Arc;
43
44use bob_core::{
45 error::ToolError,
46 ports::ToolPort,
47 types::{ToolCall, ToolDescriptor, ToolResult},
48};
49
50pub struct CompositeToolPort {
56 ports: Vec<(String, Arc<dyn ToolPort>)>,
57}
58
59impl std::fmt::Debug for CompositeToolPort {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 let ids: Vec<&str> = self.ports.iter().map(|(id, _)| id.as_str()).collect();
62 f.debug_struct("CompositeToolPort").field("ports", &ids).finish()
63 }
64}
65
66impl CompositeToolPort {
67 #[must_use]
69 pub fn new(ports: Vec<(String, Arc<dyn ToolPort>)>) -> Self {
70 Self { ports }
71 }
72}
73
74#[async_trait::async_trait]
75impl ToolPort for CompositeToolPort {
76 async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
77 let mut all = Vec::new();
78 for (_id, port) in &self.ports {
79 let tools = port.list_tools().await?;
80 all.extend(tools);
81 }
82 Ok(all)
83 }
84
85 async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
86 for (id, port) in &self.ports {
88 let prefix = format!("mcp/{id}/");
89 if call.name.starts_with(&prefix) {
90 return port.call_tool(call).await;
91 }
92 }
93 Err(ToolError::Execution(format!("no tool port owns tool '{}'", call.name)))
94 }
95}
96
97#[cfg(test)]
100mod tests {
101 use std::sync::Arc;
102
103 use bob_core::types::{ToolResult, ToolSource};
104
105 use super::*;
106
107 struct StubPort {
109 tools: Vec<ToolDescriptor>,
110 }
111
112 #[async_trait::async_trait]
113 impl ToolPort for StubPort {
114 async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
115 Ok(self.tools.clone())
116 }
117
118 async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
119 Ok(ToolResult {
120 name: call.name,
121 output: serde_json::json!({"ok": true}),
122 is_error: false,
123 })
124 }
125 }
126
127 #[tokio::test]
128 async fn lists_tools_from_all_ports() {
129 let p1 = Arc::new(StubPort {
130 tools: vec![ToolDescriptor {
131 id: "mcp/fs/read_file".into(),
132 description: "Read a file".into(),
133 input_schema: serde_json::json!({}),
134 source: ToolSource::Mcp { server: "fs".into() },
135 }],
136 });
137 let p2 = Arc::new(StubPort {
138 tools: vec![ToolDescriptor {
139 id: "mcp/git/log".into(),
140 description: "Git log".into(),
141 input_schema: serde_json::json!({}),
142 source: ToolSource::Mcp { server: "git".into() },
143 }],
144 });
145
146 let composite = CompositeToolPort::new(vec![
147 ("fs".into(), p1 as Arc<dyn ToolPort>),
148 ("git".into(), p2 as Arc<dyn ToolPort>),
149 ]);
150
151 let tools = composite.list_tools().await.ok();
152 assert_eq!(tools.as_ref().map(Vec::len), Some(2));
153 }
154
155 #[tokio::test]
156 async fn routes_call_to_correct_port() {
157 let p1 = Arc::new(StubPort {
158 tools: vec![ToolDescriptor {
159 id: "mcp/fs/read_file".into(),
160 description: "Read".into(),
161 input_schema: serde_json::json!({}),
162 source: ToolSource::Mcp { server: "fs".into() },
163 }],
164 });
165 let p2 = Arc::new(StubPort {
166 tools: vec![ToolDescriptor {
167 id: "mcp/git/log".into(),
168 description: "Log".into(),
169 input_schema: serde_json::json!({}),
170 source: ToolSource::Mcp { server: "git".into() },
171 }],
172 });
173
174 let composite = CompositeToolPort::new(vec![
175 ("fs".into(), p1 as Arc<dyn ToolPort>),
176 ("git".into(), p2 as Arc<dyn ToolPort>),
177 ]);
178
179 let call = ToolCall { name: "mcp/git/log".into(), arguments: serde_json::json!({}) };
180 let result = composite.call_tool(call).await;
181 assert!(result.is_ok());
182 assert_eq!(result.ok().map(|r| r.name), Some("mcp/git/log".into()));
183 }
184
185 #[tokio::test]
186 async fn unknown_tool_returns_error() {
187 let composite = CompositeToolPort::new(vec![]);
188 let call = ToolCall { name: "mcp/unknown/tool".into(), arguments: serde_json::json!({}) };
189 let result = composite.call_tool(call).await;
190 assert!(result.is_err());
191 }
192}