Skip to main content

bob_runtime/
composite.rs

1//! # Composite Tool Port
2//!
3//! Composite tool port — aggregates multiple [`ToolPort`] implementations.
4//!
5//! ## Overview
6//!
7//! When multiple MCP servers (or other tool sources) are configured, a
8//! `CompositeToolPort` collects tools from all inner ports and routes
9//! `call_tool` requests to the port that owns the tool based on namespace.
10//!
11//! ## Routing Strategy
12//!
13//! Each inner port is identified by a `server_id` (e.g. `"filesystem"`).
14//! Tool IDs are expected to be already namespaced (e.g. `"mcp/filesystem/read_file"`).
15//! Routing uses the first inner port whose tool list contains the requested name.
16//!
17//! ## Example
18//!
19//! ```rust,ignore
20//! use bob_runtime::composite::CompositeToolPort;
21//! use bob_core::ports::ToolPort;
22//! use std::sync::Arc;
23//!
24//! let filesystem_port: Arc<dyn ToolPort> = /* ... */;
25//! let shell_port: Arc<dyn ToolPort> = /* ... */;
26//!
27//! let composite = CompositeToolPort::new(vec![
28//!     ("filesystem".to_string(), filesystem_port),
29//!     ("shell".to_string(), shell_port),
30//! ]);
31//!
32//! // List all tools from all sources
33//! let all_tools = composite.list_tools().await?;
34//!
35//! // Call a tool - automatically routed to the correct port
36//! let result = composite.call_tool(ToolCall {
37//!     name: "mcp/filesystem/read_file".to_string(),
38//!     arguments: json!({"path": "/tmp/test.txt"}),
39//! }).await?;
40//! ```
41
42use std::sync::Arc;
43
44use bob_core::{
45    error::ToolError,
46    ports::ToolPort,
47    types::{ToolCall, ToolDescriptor, ToolResult},
48};
49
50/// A [`ToolPort`] that delegates to multiple inner ports.
51///
52/// Each inner port is identified by a `server_id` (e.g. `"filesystem"`).
53/// Tool IDs are expected to be already namespaced (e.g. `"mcp/filesystem/read_file"`).
54/// Routing uses the first inner port whose tool list contains the requested name.
55pub 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    /// Create a composite from a list of `(server_id, port)` pairs.
68    #[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        // Route to the port whose namespace matches the tool name.
87        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// ── Tests ────────────────────────────────────────────────────────────
98
99#[cfg(test)]
100mod tests {
101    use std::sync::Arc;
102
103    use bob_core::types::{ToolResult, ToolSource};
104
105    use super::*;
106
107    /// Stub tool port that returns fixed tools.
108    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}