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![
131 ToolDescriptor::new("mcp/fs/read_file", "Read a file")
132 .with_source(ToolSource::Mcp { server: "fs".into() }),
133 ],
134 });
135 let p2 = Arc::new(StubPort {
136 tools: vec![
137 ToolDescriptor::new("mcp/git/log", "Git log")
138 .with_source(ToolSource::Mcp { server: "git".into() }),
139 ],
140 });
141
142 let composite = CompositeToolPort::new(vec![
143 ("fs".into(), p1 as Arc<dyn ToolPort>),
144 ("git".into(), p2 as Arc<dyn ToolPort>),
145 ]);
146
147 let tools = composite.list_tools().await.ok();
148 assert_eq!(tools.as_ref().map(Vec::len), Some(2));
149 }
150
151 #[tokio::test]
152 async fn routes_call_to_correct_port() {
153 let p1 = Arc::new(StubPort {
154 tools: vec![
155 ToolDescriptor::new("mcp/fs/read_file", "Read")
156 .with_source(ToolSource::Mcp { server: "fs".into() }),
157 ],
158 });
159 let p2 = Arc::new(StubPort {
160 tools: vec![
161 ToolDescriptor::new("mcp/git/log", "Log")
162 .with_source(ToolSource::Mcp { server: "git".into() }),
163 ],
164 });
165
166 let composite = CompositeToolPort::new(vec![
167 ("fs".into(), p1 as Arc<dyn ToolPort>),
168 ("git".into(), p2 as Arc<dyn ToolPort>),
169 ]);
170
171 let call = ToolCall::new("mcp/git/log", serde_json::json!({}));
172 let result = composite.call_tool(call).await;
173 assert!(result.is_ok());
174 assert_eq!(result.ok().map(|r| r.name), Some("mcp/git/log".into()));
175 }
176
177 #[tokio::test]
178 async fn unknown_tool_returns_error() {
179 let composite = CompositeToolPort::new(vec![]);
180 let call = ToolCall::new("mcp/unknown/tool", serde_json::json!({}));
181 let result = composite.call_tool(call).await;
182 assert!(result.is_err());
183 }
184}