magi_tool/
pegboard.rs

1use dashmap::DashMap;
2use rmcp::{
3    RoleClient,
4    model::{CallToolRequestParam, CallToolResult},
5    service::{DynService, RunningService},
6};
7use serde_json::Value;
8use std::borrow::Cow;
9
10use crate::Tool;
11
12/// PegBoard manages tool registration and their associated MCP services with namespace support.
13///
14/// ## Namespace and Tool Name Prefixing
15///
16/// When MCP services are registered with a namespace, their tool names are automatically
17/// prefixed to avoid conflicts. For example:
18///
19/// - Service "web_search" with tool "search" becomes "web_search-search"
20/// - Service "file_search" with tool "search" becomes "file_search-search"
21///
22/// The Tool's `name` field is modified to include the prefix, so when tools are sent
23/// to the LLM, they have unique names. The PegBoard maintains the mapping between
24/// prefixed names and original names for routing tool calls back to the correct service.
25///
26/// ## Thread Safety
27///
28/// PegBoard is designed for concurrent access. All read methods use `&self` and can be
29/// called concurrently from multiple threads/tasks. For tokio usage, wrap in `Arc<PegBoard>`.
30/// See `TOKIO_USAGE.md` for detailed examples.
31pub struct PegBoard {
32    /// All registered tools with prefixed names (e.g., "namespace-tool_name")
33    /// These tools have their `name` field modified to include the prefix
34    tools: DashMap<String, Tool>,
35
36    /// MCP services with their namespaces: Vec<(namespace, service)>
37    services: Vec<(
38        String,
39        RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
40    )>,
41
42    /// Maps prefixed tool name to (service_index, original_tool_name)
43    /// Used for routing: when LLM calls "namespace-search", we look up which service
44    /// and what the original tool name was
45    tool_routing: DashMap<String, ToolRoute>,
46
47    /// Maps namespace to list of prefixed tool names in that namespace
48    namespace_tools: DashMap<String, Vec<String>>,
49}
50
51/// Routing information for a tool
52#[derive(Debug, Clone)]
53struct ToolRoute {
54    /// Index of the service that provides this tool
55    service_index: usize,
56    /// Original tool name (before prefixing)
57    original_name: String,
58}
59
60/// Helper to create a prefixed tool name
61fn prefix_tool_name(namespace: &str, tool_name: &str) -> String {
62    format!("{}-{}", namespace, tool_name)
63}
64
65impl PegBoard {
66    /// Creates a new empty PegBoard
67    pub fn new() -> Self {
68        Self {
69            tools: DashMap::new(),
70            services: Vec::new(),
71            tool_routing: DashMap::new(),
72            namespace_tools: DashMap::new(),
73        }
74    }
75
76    /// Registers a service and automatically discovers all its tools.
77    ///
78    /// This method:
79    /// 1. Calls `list_tools()` on the service to discover all available tools
80    /// 2. Converts tools from rmcp format to PegBoard's Tool format
81    /// 3. If namespace is provided, prefixes each tool name (e.g., "namespace-tool_name")
82    /// 4. If namespace is None or empty, uses original tool names (no prefixing)
83    /// 5. Adds the service to the registry
84    /// 6. Registers all tools for use with LLM
85    ///
86    /// # Arguments
87    /// * `namespace` - Optional namespace prefix. Use None or empty string if no conflicts expected
88    /// * `service` - The MCP service to register
89    ///
90    /// # Returns
91    /// * Number of tools discovered and registered
92    ///
93    /// # Example
94    /// ```ignore
95    /// // With namespace (prefixing enabled)
96    /// pegboard.add_service(Some("web".to_string()), service).await?;
97    /// // Tool "search" is now available as "web-search"
98    ///
99    /// // Without namespace (no prefixing)
100    /// pegboard.add_service(None, service).await?;
101    /// // Tool "search" keeps its original name "search"
102    /// ```
103    pub async fn add_service(
104        &mut self,
105        namespace: Option<String>,
106        service: RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
107    ) -> Result<usize, PegBoardError> {
108        // Normalize namespace: None or empty string means no namespace
109        let namespace_str = namespace.unwrap_or_default();
110        let has_namespace = !namespace_str.is_empty();
111
112        // Check if namespace already exists (only if we have one)
113        if has_namespace && self.namespace_tools.contains_key(&namespace_str) {
114            return Err(PegBoardError::NamespaceAlreadyExists(namespace_str));
115        }
116
117        // Get the service index before we consume the service
118        let service_idx = self.services.len();
119
120        // List tools from the service (always available in rmcp)
121        let tools_response = service
122            .list_tools(None) // No pagination params - get all tools
123            .await
124            .map_err(|e| PegBoardError::ServiceError(format!("Failed to list tools: {:?}", e)))?;
125
126        // Convert rmcp tools to our Tool format
127        let tools_list: Vec<Tool> = tools_response
128            .tools
129            .into_iter()
130            .map(|rmcp_tool| Tool {
131                name: rmcp_tool.name,
132                description: rmcp_tool.description.map(Cow::from),
133                input_schema: serde_json::Value::Object((*rmcp_tool.input_schema).clone()),
134            })
135            .collect();
136
137        // Register the service (store empty string if no namespace)
138        self.services.push((namespace_str.clone(), service));
139
140        // Track tool names
141        let mut registered_tool_names = Vec::new();
142
143        // Register each tool
144        for original_tool in tools_list {
145            let original_name = original_tool.name.to_string();
146
147            // Prefix name only if namespace is provided
148            let final_name = if has_namespace {
149                prefix_tool_name(&namespace_str, &original_name)
150            } else {
151                original_name.clone()
152            };
153
154            // Check if this tool name already exists
155            if self.tools.contains_key(&final_name) {
156                return Err(PegBoardError::ToolAlreadyExists(final_name));
157            }
158
159            // Create a tool with the final name (prefixed or original)
160            let mut final_tool = original_tool.clone();
161            final_tool.name = Cow::Owned(final_name.clone());
162
163            // Register the tool
164            self.tools.insert(final_name.clone(), final_tool);
165
166            // Store routing information (service index + original name)
167            self.tool_routing.insert(
168                final_name.clone(),
169                ToolRoute {
170                    service_index: service_idx,
171                    original_name,
172                },
173            );
174
175            registered_tool_names.push(final_name);
176        }
177
178        let tool_count = registered_tool_names.len();
179
180        // Only track namespace if we have one
181        if has_namespace {
182            self.namespace_tools
183                .insert(namespace_str, registered_tool_names);
184        }
185
186        Ok(tool_count)
187    }
188
189    /// Manually registers a tool with an optional namespace and service index.
190    /// If namespace is provided, the tool name will be prefixed.
191    /// If namespace is None or empty, the original tool name is used.
192    /// Prefer `add_service()` for automatic tool discovery.
193    pub fn register_tool(
194        &self,
195        namespace: Option<&str>,
196        tool: Tool,
197        service_idx: usize,
198    ) -> Result<(), PegBoardError> {
199        // Check if service index is valid
200        if service_idx >= self.services.len() {
201            return Err(PegBoardError::InvalidServiceIndex {
202                index: service_idx,
203                max: self.services.len(),
204            });
205        }
206
207        let original_name = tool.name.to_string();
208        let namespace_str = namespace.unwrap_or("");
209        let has_namespace = !namespace_str.is_empty();
210
211        // Prefix name only if namespace is provided
212        let final_name = if has_namespace {
213            prefix_tool_name(namespace_str, &original_name)
214        } else {
215            original_name.clone()
216        };
217
218        // Check if tool already exists
219        if self.tools.contains_key(&final_name) {
220            return Err(PegBoardError::ToolAlreadyExists(final_name));
221        }
222
223        // Create tool with final name (prefixed or original)
224        let mut final_tool = tool;
225        final_tool.name = Cow::Owned(final_name.clone());
226
227        // Register tool and routing
228        self.tools.insert(final_name.clone(), final_tool);
229        self.tool_routing.insert(
230            final_name.clone(),
231            ToolRoute {
232                service_index: service_idx,
233                original_name,
234            },
235        );
236
237        // Update namespace tracking (only if we have a namespace)
238        if has_namespace {
239            self.namespace_tools
240                .entry(namespace_str.to_string())
241                .or_default()
242                .push(final_name);
243        }
244
245        Ok(())
246    }
247
248    /// Gets a tool by its name (prefixed if registered with namespace, original otherwise)
249    /// This is the name that the LLM sees and uses
250    pub fn get_tool(&self, tool_name: &str) -> Option<Tool> {
251        self.tools.get(tool_name).map(|entry| entry.value().clone())
252    }
253
254    /// Selects multiple tools by their names.
255    /// Returns `Some(Vec<Tool>)` if ALL requested tools are found.
256    /// Returns `None` if ANY tool is missing.
257    ///
258    /// # Arguments
259    /// * `tool_names` - A slice of tool names (prefixed if registered with namespace)
260    ///
261    /// # Returns
262    /// * `Some(Vec<Tool>)` if all tools exist
263    /// * `None` if any tool is missing
264    ///
265    /// # Example
266    /// ```ignore
267    /// let tools = pegboard.select_tools(&["web-search", "file-read"]);
268    /// if let Some(tools) = tools {
269    ///     // All tools found, can use them
270    /// } else {
271    ///     // One or more tools not found
272    /// }
273    /// ```
274    pub fn select_tools(&self, tool_names: &[&str]) -> Option<Vec<Tool>> {
275        let mut result = Vec::with_capacity(tool_names.len());
276
277        for &tool_name in tool_names {
278            let tool = self.get_tool(tool_name)?;
279            result.push(tool);
280        }
281
282        Some(result)
283    }
284
285    /// Gets routing information for a tool by its name
286    /// Returns (service_index, original_tool_name) for routing the call
287    pub fn get_tool_route(&self, tool_name: &str) -> Option<(usize, String)> {
288        self.tool_routing.get(tool_name).map(|entry| {
289            let route = entry.value();
290            (route.service_index, route.original_name.clone())
291        })
292    }
293
294    /// Gets all tool names in a namespace (prefixed if namespace was used)
295    pub fn list_tools_in_namespace(&self, namespace: &str) -> Vec<String> {
296        self.namespace_tools
297            .get(namespace)
298            .map(|entry| entry.value().clone())
299            .unwrap_or_default()
300    }
301
302    /// Gets all Tool objects in a namespace (with names as they appear to LLM)
303    pub fn get_tools_in_namespace(&self, namespace: &str) -> Vec<Tool> {
304        self.list_tools_in_namespace(namespace)
305            .iter()
306            .filter_map(|tool_name| self.get_tool(tool_name))
307            .collect()
308    }
309
310    /// Gets all registered tool names across all namespaces
311    /// These are the names that should be sent to the LLM
312    pub fn list_all_tools(&self) -> Vec<String> {
313        self.tools.iter().map(|entry| entry.key().clone()).collect()
314    }
315
316    /// Gets all tools as a Vec
317    /// These are the tools that should be sent to the LLM
318    pub fn get_all_tools(&self) -> Vec<Tool> {
319        self.tools
320            .iter()
321            .map(|entry| entry.value().clone())
322            .collect()
323    }
324
325    /// Gets all registered namespaces
326    pub fn list_namespaces(&self) -> Vec<String> {
327        self.namespace_tools
328            .iter()
329            .map(|entry| entry.key().clone())
330            .collect()
331    }
332
333    /// Removes a tool by its prefixed name
334    pub fn unregister_tool(&self, prefixed_name: &str) -> Result<(), PegBoardError> {
335        if self.tools.remove(prefixed_name).is_none() {
336            return Err(PegBoardError::ToolNotFound(prefixed_name.to_string()));
337        }
338
339        self.tool_routing.remove(prefixed_name);
340
341        // Remove from namespace tracking
342        // We need to find which namespace this tool belongs to
343        for mut namespace_entry in self.namespace_tools.iter_mut() {
344            namespace_entry.value_mut().retain(|n| n != prefixed_name);
345        }
346
347        Ok(())
348    }
349
350    /// Removes all tools in a namespace (when removing a service)
351    pub fn unregister_namespace(&self, namespace: &str) -> Result<usize, PegBoardError> {
352        let prefixed_names = self.list_tools_in_namespace(namespace);
353        let count = prefixed_names.len();
354
355        for prefixed_name in prefixed_names {
356            self.tools.remove(&prefixed_name);
357            self.tool_routing.remove(&prefixed_name);
358        }
359
360        self.namespace_tools.remove(namespace);
361        Ok(count)
362    }
363
364    /// Returns the number of registered tools
365    pub fn tool_count(&self) -> usize {
366        self.tools.len()
367    }
368
369    /// Returns the number of registered services
370    pub fn service_count(&self) -> usize {
371        self.services.len()
372    }
373
374    /// Returns the number of registered namespaces
375    pub fn namespace_count(&self) -> usize {
376        self.namespace_tools.len()
377    }
378
379    /// Calls a tool by its name (as seen by the LLM) with the given arguments.
380    ///
381    /// This method:
382    /// 1. Looks up the routing information using the tool name
383    /// 2. Finds the service that provides this tool
384    /// 3. Calls the service's `call_tool` method with the original tool name
385    ///
386    /// # Arguments
387    /// * `tool_name` - The tool name as seen by the LLM (prefixed if namespace was used)
388    /// * `arguments` - The arguments to pass to the tool as a JSON value
389    ///
390    /// # Returns
391    /// * `CallToolResult` containing the tool's response
392    ///
393    /// # Errors
394    /// * `PegBoardError::ToolNotFound` - If the tool name is not registered
395    /// * `PegBoardError::ServiceError` - If the service call fails
396    ///
397    /// # Example
398    /// ```ignore
399    /// // LLM calls "web-search"
400    /// let result = pegboard.call_tool(
401    ///     "web-search",
402    ///     serde_json::json!({"query": "rust programming"}),
403    /// ).await?;
404    /// ```
405    pub async fn call_tool(
406        &self,
407        tool_name: &str,
408        arguments: Value,
409    ) -> Result<CallToolResult, PegBoardError> {
410        // Get routing information
411        let (service_idx, original_name) = self
412            .get_tool_route(tool_name)
413            .ok_or_else(|| PegBoardError::ToolNotFound(tool_name.to_string()))?;
414
415        // Get the service
416        let (_namespace, service) =
417            self.services
418                .get(service_idx)
419                .ok_or(PegBoardError::InvalidServiceIndex {
420                    index: service_idx,
421                    max: self.services.len(),
422                })?;
423
424        // Convert arguments to JsonObject if it's an object, otherwise use None
425        let arguments_obj = match arguments {
426            Value::Object(obj) => Some(obj),
427            Value::Null => None,
428            _ => {
429                return Err(PegBoardError::ServiceError(
430                    "Tool arguments must be a JSON object or null".to_string(),
431                ));
432            }
433        };
434
435        // Call the service's call_tool method with the original tool name
436        let param = CallToolRequestParam {
437            name: Cow::from(original_name),
438            arguments: arguments_obj,
439        };
440
441        service
442            .call_tool(param)
443            .await
444            .map_err(|e| PegBoardError::ServiceError(format!("Tool call failed: {:?}", e)))
445    }
446}
447
448impl Default for PegBoard {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454/// Errors that can occur when working with PegBoard
455#[derive(Debug, thiserror::Error)]
456pub enum PegBoardError {
457    #[error("Tool '{0}' already exists")]
458    ToolAlreadyExists(String),
459
460    #[error("Tool '{0}' not found")]
461    ToolNotFound(String),
462
463    #[error("Invalid service index {index}, max is {max}")]
464    InvalidServiceIndex { index: usize, max: usize },
465
466    #[error("Namespace '{0}' already exists")]
467    NamespaceAlreadyExists(String),
468
469    #[error("Service error: {0}")]
470    ServiceError(String),
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use schemars::JsonSchema;
477
478    #[derive(JsonSchema)]
479    #[allow(dead_code)]
480    struct TestParams {
481        value: String,
482    }
483
484    #[test]
485    fn test_prefix_tool_name() {
486        let prefixed = prefix_tool_name("web_search", "search");
487        assert_eq!(prefixed, "web_search-search");
488
489        let prefixed2 = prefix_tool_name("fs", "read_file");
490        assert_eq!(prefixed2, "fs-read_file");
491    }
492
493    #[test]
494    fn test_pegboard_register_and_get() {
495        let pegboard = PegBoard::new();
496        let tool = crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
497
498        // Initially empty
499        assert_eq!(pegboard.tool_count(), 0);
500        assert_eq!(pegboard.namespace_count(), 0);
501
502        // Register fails with invalid service index
503        assert!(
504            pegboard
505                .register_tool(Some("web"), tool.clone(), 0)
506                .is_err()
507        );
508
509        // Without namespace also fails with invalid service index
510        assert!(pegboard.register_tool(None, tool.clone(), 0).is_err());
511    }
512
513    #[test]
514    fn test_pegboard_tool_name_prefixing() {
515        // Get tool with original name "search"
516        let original_tool =
517            crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
518        assert_eq!(original_tool.name, "search");
519
520        // After registration with namespace "web", the name should be "web-search"
521        // But we can't test this without a valid service_idx
522        // The prefixing logic is tested in test_prefix_tool_name
523    }
524
525    #[test]
526    fn test_pegboard_namespace_operations() {
527        let pegboard = PegBoard::new();
528
529        // Initially empty
530        assert_eq!(pegboard.list_namespaces().len(), 0);
531        assert_eq!(pegboard.list_all_tools().len(), 0);
532        assert_eq!(pegboard.get_all_tools().len(), 0);
533
534        // List tools in non-existent namespace returns empty
535        assert_eq!(pegboard.list_tools_in_namespace("nonexistent").len(), 0);
536        assert_eq!(pegboard.get_tools_in_namespace("nonexistent").len(), 0);
537    }
538
539    #[test]
540    fn test_pegboard_get_tool_methods() {
541        let pegboard = PegBoard::new();
542
543        // Get non-existent tool returns None (using prefixed name)
544        assert!(pegboard.get_tool("web-search").is_none());
545        assert!(pegboard.get_tool_route("web-search").is_none());
546    }
547
548    #[test]
549    fn test_pegboard_unregister() {
550        let pegboard = PegBoard::new();
551
552        // Unregister non-existent tool should fail (using prefixed name)
553        assert!(pegboard.unregister_tool("web-nonexistent").is_err());
554
555        // Unregister non-existent namespace
556        let result = pegboard.unregister_namespace("nonexistent");
557        assert!(result.is_ok());
558        assert_eq!(result.unwrap(), 0); // 0 tools removed
559    }
560
561    #[test]
562    fn test_tool_route_structure() {
563        let route = ToolRoute {
564            service_index: 0,
565            original_name: "search".to_string(),
566        };
567
568        assert_eq!(route.service_index, 0);
569        assert_eq!(route.original_name, "search");
570    }
571
572    #[test]
573    fn test_optional_namespace() {
574        // Test that None and Some("") both mean no namespace
575        let namespace_none: Option<String> = None;
576        let namespace_empty = Some(String::new());
577
578        // Both should normalize to empty string
579        let ns1 = namespace_none.unwrap_or_default();
580        let ns2 = namespace_empty.unwrap_or_default();
581
582        assert_eq!(ns1, "");
583        assert_eq!(ns2, "");
584        assert!(!ns1.is_empty() == false);
585        assert!(!ns2.is_empty() == false);
586    }
587
588    #[test]
589    fn test_prefix_only_when_namespace_provided() {
590        let original_name = "search";
591
592        // With namespace - should prefix
593        let with_ns = prefix_tool_name("web", original_name);
594        assert_eq!(with_ns, "web-search");
595
596        // Without namespace - would use original (tested via add_service logic)
597        // The prefix_tool_name function always prefixes, but add_service checks has_namespace first
598    }
599
600    // Note: Integration test for add_service() will be added once we have a proper
601    // way to create RunningService instances for testing. For now, the logic is
602    // validated through unit tests of individual components.
603}