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;
9use std::sync::Arc;
10
11use crate::Tool;
12
13/// Internal implementation of PegBoard that manages tool registration and their associated MCP services.
14///
15/// Use `PegBoard` (which is `Arc<InternalPegBoard>`) instead of using this directly.
16struct InternalPegBoard {
17    /// All registered tools with prefixed names (e.g., "namespace-tool_name")
18    /// These tools have their `name` field modified to include the prefix
19    tools: DashMap<String, Tool>,
20
21    /// MCP services with their namespaces: DashMap<mcp_id, (namespace, service)>
22    /// DashMap provides lock-free concurrent access
23    services: DashMap<
24        String,
25        (
26            String,
27            RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
28        ),
29    >,
30
31    /// Maps prefixed tool name to (service_id, original_tool_name)
32    /// Used for routing: when LLM calls "namespace-search", we look up which service
33    /// and what the original tool name was
34    tool_routing: DashMap<String, ToolRoute>,
35
36    /// Maps namespace to list of prefixed tool names in that namespace
37    namespace_tools: DashMap<String, Vec<String>>,
38
39    /// Tool discovery: maps tool name to list of related tool names
40    /// When a tool is used, these related tools can be discovered and added dynamically
41    tool_discovery: DashMap<String, Vec<String>>,
42
43    /// MCP discovery: maps mcp_id to list of related mcp_ids
44    /// When an MCP service is used, these related MCPs can be discovered
45    mcp_discovery: DashMap<String, Vec<String>>,
46}
47
48/// Routing information for a tool
49#[derive(Debug, Clone)]
50struct ToolRoute {
51    /// ID of the service that provides this tool
52    service_id: String,
53    /// Original tool name (before prefixing)
54    original_name: String,
55}
56
57/// Helper to create a prefixed tool name
58fn prefix_tool_name(namespace: &str, tool_name: &str) -> String {
59    format!("{}-{}", namespace, tool_name)
60}
61
62impl InternalPegBoard {
63    /// Creates a new empty InternalPegBoard
64    fn new() -> Self {
65        Self {
66            tools: DashMap::new(),
67            services: DashMap::new(),
68            tool_routing: DashMap::new(),
69            namespace_tools: DashMap::new(),
70            tool_discovery: DashMap::new(),
71            mcp_discovery: DashMap::new(),
72        }
73    }
74
75    /// Registers a service and automatically discovers all its tools.
76    ///
77    /// This method:
78    /// 1. Calls `list_tools()` on the service to discover all available tools
79    /// 2. Converts tools from rmcp format to PegBoard's Tool format
80    /// 3. If namespace is provided, prefixes each tool name (e.g., "namespace-tool_name")
81    /// 4. If namespace is None or empty, uses original tool names (no prefixing)
82    /// 5. Adds the service to the registry
83    /// 6. Registers all tools for use with LLM
84    ///
85    /// # Arguments
86    /// * `mcp_id` - Unique identifier for this MCP service (used for discovery and routing)
87    /// * `namespace` - Optional namespace prefix. Use None or empty string if no conflicts expected
88    /// * `service` - The MCP service to register
89    ///
90    /// # Example
91    /// ```ignore
92    /// // With namespace (prefixing enabled)
93    /// pegboard.add_service("web-mcp".to_string(), Some("web".to_string()), service).await?;
94    /// // Tool "search" is now available as "web-search"
95    ///
96    /// // Without namespace (no prefixing)
97    /// pegboard.add_service("file-mcp".to_string(), None, service).await?;
98    /// // Tool "search" keeps its original name "search"
99    /// ```
100    pub async fn add_service(
101        &self,
102        mcp_id: String,
103        namespace: Option<String>,
104        service: RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
105    ) -> Result<(), PegBoardError> {
106        // Check if mcp_id already exists
107        if self.services.contains_key(&mcp_id) {
108            return Err(PegBoardError::DuplicateMcpId(mcp_id));
109        }
110
111        // Normalize namespace: None or empty string means no namespace
112        let namespace_str = namespace.unwrap_or_default();
113        let has_namespace = !namespace_str.is_empty();
114
115        // Check if namespace already exists (only if we have one)
116        if has_namespace && self.namespace_tools.contains_key(&namespace_str) {
117            return Err(PegBoardError::NamespaceAlreadyExists(namespace_str));
118        }
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
139            .insert(mcp_id.clone(), (namespace_str.clone(), service));
140
141        // Track tool names
142        let mut registered_tool_names = Vec::new();
143
144        // Register each tool
145        for original_tool in tools_list {
146            let original_name = original_tool.name.to_string();
147
148            // Prefix name only if namespace is provided
149            let final_name = if has_namespace {
150                prefix_tool_name(&namespace_str, &original_name)
151            } else {
152                original_name.clone()
153            };
154
155            // Check if this tool name already exists
156            if self.tools.contains_key(&final_name) {
157                return Err(PegBoardError::ToolAlreadyExists(final_name));
158            }
159
160            // Create a tool with the final name (prefixed or original)
161            let mut final_tool = original_tool.clone();
162            final_tool.name = Cow::Owned(final_name.clone());
163
164            // Register the tool
165            self.tools.insert(final_name.clone(), final_tool);
166
167            // Store routing information (service_id + original name)
168            self.tool_routing.insert(
169                final_name.clone(),
170                ToolRoute {
171                    service_id: mcp_id.clone(),
172                    original_name,
173                },
174            );
175
176            registered_tool_names.push(final_name);
177        }
178
179        // Only track namespace if we have one
180        if has_namespace {
181            self.namespace_tools
182                .insert(namespace_str, registered_tool_names);
183        }
184
185        Ok(())
186    }
187
188    /// Manually registers a tool with an optional namespace and service ID.
189    /// If namespace is provided, the tool name will be prefixed.
190    /// If namespace is None or empty, the original tool name is used.
191    /// Prefer `add_service()` for automatic tool discovery.
192    pub fn register_tool(
193        &self,
194        namespace: Option<&str>,
195        tool: Tool,
196        service_id: &str,
197    ) -> Result<(), PegBoardError> {
198        // Check if service ID is valid
199        if !self.services.contains_key(service_id) {
200            return Err(PegBoardError::InvalidServiceId(service_id.to_string()));
201        }
202
203        let original_name = tool.name.to_string();
204        let namespace_str = namespace.unwrap_or("");
205        let has_namespace = !namespace_str.is_empty();
206
207        // Prefix name only if namespace is provided
208        let final_name = if has_namespace {
209            prefix_tool_name(namespace_str, &original_name)
210        } else {
211            original_name.clone()
212        };
213
214        // Check if tool already exists
215        if self.tools.contains_key(&final_name) {
216            return Err(PegBoardError::ToolAlreadyExists(final_name));
217        }
218
219        // Create tool with final name (prefixed or original)
220        let mut final_tool = tool;
221        final_tool.name = Cow::Owned(final_name.clone());
222
223        // Register tool and routing
224        self.tools.insert(final_name.clone(), final_tool);
225        self.tool_routing.insert(
226            final_name.clone(),
227            ToolRoute {
228                service_id: service_id.to_string(),
229                original_name,
230            },
231        );
232
233        // Update namespace tracking (only if we have a namespace)
234        if has_namespace {
235            self.namespace_tools
236                .entry(namespace_str.to_string())
237                .or_default()
238                .push(final_name);
239        }
240
241        Ok(())
242    }
243
244    /// Gets a tool by its name (prefixed if registered with namespace, original otherwise)
245    /// This is the name that the LLM sees and uses
246    pub fn get_tool(&self, tool_name: &str) -> Option<Tool> {
247        self.tools.get(tool_name).map(|entry| entry.value().clone())
248    }
249
250    /// Selects multiple tools by their names.
251    /// Returns `Some(Vec<Tool>)` if ALL requested tools are found.
252    /// Returns `None` if ANY tool is missing.
253    ///
254    /// # Arguments
255    /// * `tool_names` - A slice of tool names (prefixed if registered with namespace)
256    ///
257    /// # Returns
258    /// * `Some(Vec<Tool>)` if all tools exist
259    /// * `None` if any tool is missing
260    ///
261    /// # Example
262    /// ```ignore
263    /// let tools = pegboard.select_tools(&["web-search", "file-read"]);
264    /// if let Some(tools) = tools {
265    ///     // All tools found, can use them
266    /// } else {
267    ///     // One or more tools not found
268    /// }
269    /// ```
270    pub fn select_tools(&self, tool_names: &[&str]) -> Option<Vec<Tool>> {
271        let mut result = Vec::with_capacity(tool_names.len());
272
273        for &tool_name in tool_names {
274            let tool = self.get_tool(tool_name)?;
275            result.push(tool);
276        }
277
278        Some(result)
279    }
280
281    /// Gets routing information for a tool by its name
282    /// Returns (service_id, original_tool_name) for routing the call
283    pub fn get_tool_route(&self, tool_name: &str) -> Option<(String, String)> {
284        self.tool_routing.get(tool_name).map(|entry| {
285            let route = entry.value();
286            (route.service_id.clone(), route.original_name.clone())
287        })
288    }
289
290    /// Gets all tool names in a namespace (prefixed if namespace was used)
291    pub fn list_tools_in_namespace(&self, namespace: &str) -> Vec<String> {
292        self.namespace_tools
293            .get(namespace)
294            .map(|entry| entry.value().clone())
295            .unwrap_or_default()
296    }
297
298    /// Gets all Tool objects in a namespace (with names as they appear to LLM)
299    pub fn get_tools_in_namespace(&self, namespace: &str) -> Vec<Tool> {
300        self.list_tools_in_namespace(namespace)
301            .iter()
302            .filter_map(|tool_name| self.get_tool(tool_name))
303            .collect()
304    }
305
306    /// Gets all registered tool names across all namespaces
307    /// These are the names that should be sent to the LLM
308    pub fn list_all_tools(&self) -> Vec<String> {
309        self.tools.iter().map(|entry| entry.key().clone()).collect()
310    }
311
312    /// Gets all tools as a Vec
313    /// These are the tools that should be sent to the LLM
314    pub fn get_all_tools(&self) -> Vec<Tool> {
315        self.tools
316            .iter()
317            .map(|entry| entry.value().clone())
318            .collect()
319    }
320
321    /// Gets all registered namespaces
322    pub fn list_namespaces(&self) -> Vec<String> {
323        self.namespace_tools
324            .iter()
325            .map(|entry| entry.key().clone())
326            .collect()
327    }
328
329    /// Removes a tool by its prefixed name
330    pub fn unregister_tool(&self, prefixed_name: &str) -> Result<(), PegBoardError> {
331        if self.tools.remove(prefixed_name).is_none() {
332            return Err(PegBoardError::ToolNotFound(prefixed_name.to_string()));
333        }
334
335        self.tool_routing.remove(prefixed_name);
336
337        // Remove from namespace tracking
338        // We need to find which namespace this tool belongs to
339        for mut namespace_entry in self.namespace_tools.iter_mut() {
340            namespace_entry.value_mut().retain(|n| n != prefixed_name);
341        }
342
343        Ok(())
344    }
345
346    /// Removes all tools in a namespace and the associated service
347    pub fn unregister_namespace(&self, namespace: &str) -> Result<usize, PegBoardError> {
348        let prefixed_names = self.list_tools_in_namespace(namespace);
349        let count = prefixed_names.len();
350
351        // Remove all tools in this namespace
352        for prefixed_name in prefixed_names {
353            self.tools.remove(&prefixed_name);
354            self.tool_routing.remove(&prefixed_name);
355        }
356
357        // Find and remove the service with this namespace
358        let service_id_to_remove = self
359            .services
360            .iter()
361            .find(|entry| entry.value().0 == namespace)
362            .map(|entry| entry.key().clone());
363
364        if let Some(id) = service_id_to_remove {
365            self.services.remove(&id);
366        }
367
368        self.namespace_tools.remove(namespace);
369        Ok(count)
370    }
371
372    /// Removes a service by its ID and all associated tools
373    pub fn unregister_service(&self, service_id: &str) -> Result<usize, PegBoardError> {
374        // Get the namespace for this service
375        let namespace = self
376            .services
377            .get(service_id)
378            .map(|entry| entry.value().0.clone())
379            .ok_or(PegBoardError::InvalidServiceId(service_id.to_string()))?;
380
381        // Find and remove all tools associated with this service ID
382        let tools_to_remove: Vec<String> = self
383            .tool_routing
384            .iter()
385            .filter(|entry| entry.value().service_id == service_id)
386            .map(|entry| entry.key().clone())
387            .collect();
388
389        let count = tools_to_remove.len();
390        for tool_name in tools_to_remove {
391            self.tools.remove(&tool_name);
392            self.tool_routing.remove(&tool_name);
393        }
394
395        // Remove the service
396        self.services.remove(service_id);
397
398        // If it has a namespace, also clean up namespace tracking
399        if !namespace.is_empty() {
400            self.namespace_tools.remove(&namespace);
401        }
402
403        Ok(count)
404    }
405
406    /// Returns the number of registered tools
407    pub fn tool_count(&self) -> usize {
408        self.tools.len()
409    }
410
411    /// Returns the number of registered services
412    pub fn service_count(&self) -> usize {
413        self.services.len()
414    }
415
416    /// Returns the number of registered namespaces
417    pub fn namespace_count(&self) -> usize {
418        self.namespace_tools.len()
419    }
420
421    /// Calls a tool by its name (as seen by the LLM) with the given arguments.
422    ///
423    /// This method:
424    /// 1. Looks up the routing information using the tool name
425    /// 2. Finds the service that provides this tool
426    /// 3. Calls the service's `call_tool` method with the original tool name
427    ///
428    /// # Arguments
429    /// * `tool_name` - The tool name as seen by the LLM (prefixed if namespace was used)
430    /// * `arguments` - The arguments to pass to the tool as a JSON value
431    ///
432    /// # Returns
433    /// * `CallToolResult` containing the tool's response
434    ///
435    /// # Errors
436    /// * `PegBoardError::ToolNotFound` - If the tool name is not registered
437    /// * `PegBoardError::ServiceError` - If the service call fails
438    ///
439    /// # Example
440    /// ```ignore
441    /// // LLM calls "web-search"
442    /// let result = pegboard.call_tool(
443    ///     "web-search",
444    ///     serde_json::json!({"query": "rust programming"}),
445    /// ).await?;
446    /// ```
447    pub async fn call_tool(
448        &self,
449        tool_name: &str,
450        arguments: Value,
451    ) -> Result<CallToolResult, PegBoardError> {
452        // Get routing information
453        let (service_id, original_name) = self
454            .get_tool_route(tool_name)
455            .ok_or_else(|| PegBoardError::ToolNotFound(tool_name.to_string()))?;
456
457        // Get the service
458        let service_entry = self
459            .services
460            .get(&service_id)
461            .ok_or(PegBoardError::InvalidServiceId(service_id.clone()))?;
462        let (_namespace, service) = service_entry.value();
463
464        // Convert arguments to JsonObject if it's an object, otherwise use None
465        let arguments_obj = match arguments {
466            Value::Object(obj) => Some(obj),
467            Value::Null => None,
468            _ => {
469                return Err(PegBoardError::ServiceError(
470                    "Tool arguments must be a JSON object or null".to_string(),
471                ));
472            }
473        };
474
475        // Call the service's call_tool method with the original tool name
476        let param = CallToolRequestParam {
477            name: Cow::from(original_name),
478            arguments: arguments_obj,
479        };
480
481        service
482            .call_tool(param)
483            .await
484            .map_err(|e| PegBoardError::ServiceError(format!("Tool call failed: {:?}", e)))
485    }
486
487    /// Registers a tool discovery relationship.
488    /// When `tool_name` is used, the related tools can be discovered via `discover_tool()`.
489    /// This replaces any existing discovery relationships for the tool.
490    ///
491    /// # Arguments
492    /// * `tool_name` - The tool name (prefixed if namespace was used during registration)
493    /// * `related_tools` - List of related tool names that should be discovered when this tool is used
494    ///
495    /// # Example
496    /// ```ignore
497    /// // When "web-search" is used, suggest "web-fetch" and "web-parse"
498    /// pegboard.register_tool_discovery(
499    ///     "web-search",
500    ///     vec!["web-fetch".to_string(), "web-parse".to_string()]
501    /// );
502    /// ```
503    pub fn register_tool_discovery(&self, tool_name: &str, related_tools: Vec<String>) {
504        self.tool_discovery
505            .insert(tool_name.to_string(), related_tools);
506    }
507
508    /// Discovers related tools for a given tool name.
509    /// Returns the full Tool objects for all related tools that are registered.
510    /// Returns an empty Vec if no discovery relationships exist for this tool.
511    ///
512    /// # Arguments
513    /// * `tool_name` - The tool name to discover related tools for
514    ///
515    /// # Returns
516    /// * `Vec<Tool>` - List of related Tool objects (empty if none registered)
517    ///
518    /// # Example
519    /// ```ignore
520    /// let related_tools = pegboard.discover_tool("web-search");
521    /// // Returns Tool objects for "web-fetch" and "web-parse" if they're registered
522    /// ```
523    pub fn discover_tool(&self, tool_name: &str) -> Vec<Tool> {
524        self.tool_discovery
525            .get(tool_name)
526            .map(|entry| {
527                entry
528                    .value()
529                    .iter()
530                    .filter_map(|name| self.get_tool(name))
531                    .collect()
532            })
533            .unwrap_or_default()
534    }
535
536    /// Registers an MCP discovery relationship.
537    /// When `mcp_id` is used, the related MCPs can be discovered via `discover_mcp()`.
538    /// This replaces any existing discovery relationships for the MCP.
539    ///
540    /// # Arguments
541    /// * `mcp_id` - The MCP ID to register discovery for
542    /// * `related_mcps` - List of related MCP IDs that should be discovered when this MCP is used
543    ///
544    /// # Example
545    /// ```ignore
546    /// // When "web-mcp" is used, suggest "html-parser-mcp" and "image-fetcher-mcp"
547    /// pegboard.register_mcp_discovery(
548    ///     "web-mcp",
549    ///     vec!["html-parser-mcp".to_string(), "image-fetcher-mcp".to_string()]
550    /// );
551    /// ```
552    pub fn register_mcp_discovery(&self, mcp_id: &str, related_mcps: Vec<String>) {
553        self.mcp_discovery.insert(mcp_id.to_string(), related_mcps);
554    }
555
556    /// Discovers related MCP IDs for a given MCP ID.
557    /// Returns a list of related MCP IDs.
558    /// Returns an empty Vec if no discovery relationships exist for this MCP.
559    ///
560    /// # Arguments
561    /// * `mcp_id` - The MCP ID to discover related MCPs for
562    ///
563    /// # Returns
564    /// * `Vec<String>` - List of related MCP IDs (empty if none registered)
565    ///
566    /// # Example
567    /// ```ignore
568    /// let related_mcps = pegboard.discover_mcp("web-mcp");
569    /// // Returns ["html-parser-mcp", "image-fetcher-mcp"]
570    /// ```
571    pub fn discover_mcp(&self, mcp_id: &str) -> Vec<String> {
572        self.mcp_discovery
573            .get(mcp_id)
574            .map(|entry| entry.value().clone())
575            .unwrap_or_default()
576    }
577}
578
579/// PegBoard manages tool registration and their associated MCP services with namespace support.
580///
581/// ## Namespace and Tool Name Prefixing
582///
583/// When MCP services are registered with a namespace, their tool names are automatically
584/// prefixed to avoid conflicts. For example:
585///
586/// - Service "web_search" with tool "search" becomes "web_search-search"
587/// - Service "file_search" with tool "search" becomes "file_search-search"
588///
589/// The Tool's `name` field is modified to include the prefix, so when tools are sent
590/// to the LLM, they have unique names. The PegBoard maintains the mapping between
591/// prefixed names and original names for routing tool calls back to the correct service.
592///
593/// ## Thread Safety
594///
595/// PegBoard is designed for concurrent access and internally uses `Arc` for cheap cloning.
596/// It uses `DashMap` for all internal storage, providing lock-free concurrent access.
597/// All methods use `&self` and can be called concurrently from multiple threads/tasks.
598/// Simply clone the PegBoard to share it across async tasks.
599///
600/// ## Example
601///
602/// ```ignore
603/// let pegboard = PegBoard::new();
604/// let pegboard_clone = pegboard.clone(); // Cheap Arc clone
605/// ```
606#[derive(Clone)]
607pub struct PegBoard {
608    inner: Arc<InternalPegBoard>,
609}
610
611impl PegBoard {
612    /// Creates a new empty PegBoard
613    pub fn new() -> Self {
614        Self {
615            inner: Arc::new(InternalPegBoard::new()),
616        }
617    }
618
619    /// Registers a service and automatically discovers all its tools.
620    ///
621    /// See `InternalPegBoard::add_service` for full documentation.
622    pub async fn add_service(
623        &self,
624        mcp_id: String,
625        namespace: Option<String>,
626        service: RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
627    ) -> Result<(), PegBoardError> {
628        self.inner.add_service(mcp_id, namespace, service).await
629    }
630
631    /// Manually registers a tool with an optional namespace and service ID.
632    pub fn register_tool(
633        &self,
634        namespace: Option<&str>,
635        tool: Tool,
636        service_id: &str,
637    ) -> Result<(), PegBoardError> {
638        self.inner.register_tool(namespace, tool, service_id)
639    }
640
641    /// Gets a tool by its name (prefixed if registered with namespace, original otherwise)
642    pub fn get_tool(&self, tool_name: &str) -> Option<Tool> {
643        self.inner.get_tool(tool_name)
644    }
645
646    /// Selects multiple tools by their names.
647    pub fn select_tools(&self, tool_names: &[&str]) -> Option<Vec<Tool>> {
648        self.inner.select_tools(tool_names)
649    }
650
651    /// Gets routing information for a tool by its name
652    pub fn get_tool_route(&self, tool_name: &str) -> Option<(String, String)> {
653        self.inner.get_tool_route(tool_name)
654    }
655
656    /// Gets all tool names in a namespace (prefixed if namespace was used)
657    pub fn list_tools_in_namespace(&self, namespace: &str) -> Vec<String> {
658        self.inner.list_tools_in_namespace(namespace)
659    }
660
661    /// Gets all Tool objects in a namespace (with names as they appear to LLM)
662    pub fn get_tools_in_namespace(&self, namespace: &str) -> Vec<Tool> {
663        self.inner.get_tools_in_namespace(namespace)
664    }
665
666    /// Gets all registered tool names across all namespaces
667    pub fn list_all_tools(&self) -> Vec<String> {
668        self.inner.list_all_tools()
669    }
670
671    /// Gets all tools as a Vec
672    pub fn get_all_tools(&self) -> Vec<Tool> {
673        self.inner.get_all_tools()
674    }
675
676    /// Gets all registered namespaces
677    pub fn list_namespaces(&self) -> Vec<String> {
678        self.inner.list_namespaces()
679    }
680
681    /// Removes a tool by its prefixed name
682    pub fn unregister_tool(&self, prefixed_name: &str) -> Result<(), PegBoardError> {
683        self.inner.unregister_tool(prefixed_name)
684    }
685
686    /// Removes all tools in a namespace and the associated service
687    pub fn unregister_namespace(&self, namespace: &str) -> Result<usize, PegBoardError> {
688        self.inner.unregister_namespace(namespace)
689    }
690
691    /// Removes a service by its ID and all associated tools
692    pub fn unregister_service(&self, service_id: &str) -> Result<usize, PegBoardError> {
693        self.inner.unregister_service(service_id)
694    }
695
696    /// Returns the number of registered tools
697    pub fn tool_count(&self) -> usize {
698        self.inner.tool_count()
699    }
700
701    /// Returns the number of registered services
702    pub fn service_count(&self) -> usize {
703        self.inner.service_count()
704    }
705
706    /// Returns the number of registered namespaces
707    pub fn namespace_count(&self) -> usize {
708        self.inner.namespace_count()
709    }
710
711    /// Calls a tool by its name (as seen by the LLM) with the given arguments.
712    pub async fn call_tool(
713        &self,
714        tool_name: &str,
715        arguments: Value,
716    ) -> Result<CallToolResult, PegBoardError> {
717        self.inner.call_tool(tool_name, arguments).await
718    }
719
720    /// Registers a tool discovery relationship.
721    pub fn register_tool_discovery(&self, tool_name: &str, related_tools: Vec<String>) {
722        self.inner.register_tool_discovery(tool_name, related_tools)
723    }
724
725    /// Discovers related tools for a given tool name.
726    pub fn discover_tool(&self, tool_name: &str) -> Vec<Tool> {
727        self.inner.discover_tool(tool_name)
728    }
729
730    /// Registers an MCP discovery relationship.
731    pub fn register_mcp_discovery(&self, mcp_id: &str, related_mcps: Vec<String>) {
732        self.inner.register_mcp_discovery(mcp_id, related_mcps)
733    }
734
735    /// Discovers related MCP IDs for a given MCP ID.
736    pub fn discover_mcp(&self, mcp_id: &str) -> Vec<String> {
737        self.inner.discover_mcp(mcp_id)
738    }
739}
740
741impl Default for PegBoard {
742    fn default() -> Self {
743        Self::new()
744    }
745}
746
747/// Errors that can occur when working with PegBoard
748#[derive(Debug, thiserror::Error)]
749pub enum PegBoardError {
750    #[error("Tool '{0}' already exists")]
751    ToolAlreadyExists(String),
752
753    #[error("Tool '{0}' not found")]
754    ToolNotFound(String),
755
756    #[error("Invalid service ID '{0}'")]
757    InvalidServiceId(String),
758
759    #[error("MCP ID '{0}' already exists")]
760    DuplicateMcpId(String),
761
762    #[error("Namespace '{0}' already exists")]
763    NamespaceAlreadyExists(String),
764
765    #[error("Service error: {0}")]
766    ServiceError(String),
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use schemars::JsonSchema;
773
774    #[derive(JsonSchema)]
775    #[allow(dead_code)]
776    struct TestParams {
777        value: String,
778    }
779
780    #[test]
781    fn test_prefix_tool_name() {
782        let prefixed = prefix_tool_name("web_search", "search");
783        assert_eq!(prefixed, "web_search-search");
784
785        let prefixed2 = prefix_tool_name("fs", "read_file");
786        assert_eq!(prefixed2, "fs-read_file");
787    }
788
789    #[test]
790    fn test_pegboard_register_and_get() {
791        let pegboard = PegBoard::new();
792        let tool = crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
793
794        // Initially empty
795        assert_eq!(pegboard.tool_count(), 0);
796        assert_eq!(pegboard.namespace_count(), 0);
797
798        // Register fails with invalid service ID
799        assert!(
800            pegboard
801                .register_tool(Some("web"), tool.clone(), "invalid-service-id")
802                .is_err()
803        );
804
805        // Without namespace also fails with invalid service ID
806        assert!(
807            pegboard
808                .register_tool(None, tool.clone(), "invalid-service-id")
809                .is_err()
810        );
811    }
812
813    #[test]
814    fn test_pegboard_tool_name_prefixing() {
815        // Get tool with original name "search"
816        let original_tool =
817            crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
818        assert_eq!(original_tool.name, "search");
819
820        // After registration with namespace "web", the name should be "web-search"
821        // But we can't test this without a valid service_idx
822        // The prefixing logic is tested in test_prefix_tool_name
823    }
824
825    #[test]
826    fn test_pegboard_namespace_operations() {
827        let pegboard = PegBoard::new();
828
829        // Initially empty
830        assert_eq!(pegboard.list_namespaces().len(), 0);
831        assert_eq!(pegboard.list_all_tools().len(), 0);
832        assert_eq!(pegboard.get_all_tools().len(), 0);
833
834        // List tools in non-existent namespace returns empty
835        assert_eq!(pegboard.list_tools_in_namespace("nonexistent").len(), 0);
836        assert_eq!(pegboard.get_tools_in_namespace("nonexistent").len(), 0);
837    }
838
839    #[test]
840    fn test_pegboard_get_tool_methods() {
841        let pegboard = PegBoard::new();
842
843        // Get non-existent tool returns None (using prefixed name)
844        assert!(pegboard.get_tool("web-search").is_none());
845        assert!(pegboard.get_tool_route("web-search").is_none());
846    }
847
848    #[test]
849    fn test_pegboard_unregister() {
850        let pegboard = PegBoard::new();
851
852        // Unregister non-existent tool should fail (using prefixed name)
853        assert!(pegboard.unregister_tool("web-nonexistent").is_err());
854
855        // Unregister non-existent namespace
856        let result = pegboard.unregister_namespace("nonexistent");
857        assert!(result.is_ok());
858        assert_eq!(result.unwrap(), 0); // 0 tools removed
859    }
860
861    #[test]
862    fn test_tool_route_structure() {
863        let route = ToolRoute {
864            service_id: "test-mcp".to_string(),
865            original_name: "search".to_string(),
866        };
867
868        assert_eq!(route.service_id, "test-mcp");
869        assert_eq!(route.original_name, "search");
870    }
871
872    #[test]
873    fn test_optional_namespace() {
874        // Test that None and Some("") both mean no namespace
875        let namespace_none: Option<String> = None;
876        let namespace_empty = Some(String::new());
877
878        // Both should normalize to empty string
879        let ns1 = namespace_none.unwrap_or_default();
880        let ns2 = namespace_empty.unwrap_or_default();
881
882        assert_eq!(ns1, "");
883        assert_eq!(ns2, "");
884        assert!(!ns1.is_empty() == false);
885        assert!(!ns2.is_empty() == false);
886    }
887
888    #[test]
889    fn test_prefix_only_when_namespace_provided() {
890        let original_name = "search";
891
892        // With namespace - should prefix
893        let with_ns = prefix_tool_name("web", original_name);
894        assert_eq!(with_ns, "web-search");
895
896        // Without namespace - would use original (tested via add_service logic)
897        // The prefix_tool_name function always prefixes, but add_service checks has_namespace first
898    }
899
900    // Note: Integration test for add_service() will be added once we have a proper
901    // way to create RunningService instances for testing. For now, the logic is
902    // validated through unit tests of individual components.
903
904    #[test]
905    fn test_tool_discovery() {
906        let pegboard = PegBoard::new();
907
908        // Initially, discovering a non-existent tool returns empty
909        let discovered = pegboard.discover_tool("web-search");
910        assert_eq!(discovered.len(), 0);
911
912        // Register discovery relationship
913        pegboard.register_tool_discovery(
914            "web-search",
915            vec!["web-fetch".to_string(), "web-parse".to_string()],
916        );
917
918        // Discover should return empty if related tools don't exist
919        let discovered = pegboard.discover_tool("web-search");
920        assert_eq!(discovered.len(), 0);
921
922        // Replace discovery relationship
923        pegboard.register_tool_discovery("web-search", vec!["other-tool".to_string()]);
924
925        // Discovery is replaced (not appended)
926        let discovered = pegboard.discover_tool("web-search");
927        assert_eq!(discovered.len(), 0); // Still empty since tools don't exist
928    }
929
930    #[test]
931    fn test_mcp_discovery() {
932        let pegboard = PegBoard::new();
933
934        // Initially, discovering a non-existent MCP returns empty
935        let discovered = pegboard.discover_mcp("web-mcp");
936        assert_eq!(discovered.len(), 0);
937
938        // Register MCP discovery relationship
939        pegboard.register_mcp_discovery(
940            "web-mcp",
941            vec![
942                "html-parser-mcp".to_string(),
943                "image-fetcher-mcp".to_string(),
944            ],
945        );
946
947        // Discover returns the registered related MCPs
948        let discovered = pegboard.discover_mcp("web-mcp");
949        assert_eq!(discovered.len(), 2);
950        assert!(discovered.contains(&"html-parser-mcp".to_string()));
951        assert!(discovered.contains(&"image-fetcher-mcp".to_string()));
952
953        // Replace MCP discovery relationship
954        pegboard.register_mcp_discovery("web-mcp", vec!["other-mcp".to_string()]);
955
956        // Discovery is replaced (not appended)
957        let discovered = pegboard.discover_mcp("web-mcp");
958        assert_eq!(discovered.len(), 1);
959        assert_eq!(discovered[0], "other-mcp");
960    }
961}