Skip to main content

magi_tool/
pegboard.rs

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