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        // Clone the protocol peer so the request future does not retain the
472        // dynamic service or its map guard while waiting for the tool result.
473        let peer = {
474            let service_entry = self
475                .services
476                .get(&service_id)
477                .ok_or(PegBoardError::InvalidServiceId(service_id.clone()))?;
478            service_entry.value().1.peer().clone()
479        };
480
481        // Convert arguments to JsonObject if it's an object, otherwise use None
482        let arguments_obj = match arguments {
483            Value::Object(obj) => Some(obj),
484            Value::Null => None,
485            _ => {
486                return Err(PegBoardError::ServiceError(
487                    "Tool arguments must be a JSON object or null".to_string(),
488                ));
489            }
490        };
491
492        // Call the service's call_tool method with the original tool name
493        let param = match arguments_obj {
494            Some(obj) => CallToolRequestParams::new(original_name).with_arguments(obj),
495            None => CallToolRequestParams::new(original_name),
496        };
497
498        peer.call_tool(param)
499            .await
500            .map_err(|e| PegBoardError::ServiceError(format!("Tool call failed: {:?}", e)))
501    }
502
503    /// Registers a tool discovery relationship.
504    /// When `tool_name` is used, the related tools can be discovered via `discover_tool()`.
505    /// This replaces any existing discovery relationships for the tool.
506    ///
507    /// # Arguments
508    /// * `tool_name` - The tool name (prefixed if namespace was used during registration)
509    /// * `related_tools` - List of related tool names that should be discovered when this tool is used
510    ///
511    /// # Example
512    /// ```ignore
513    /// // When "web-search" is used, suggest "web-fetch" and "web-parse"
514    /// pegboard.register_tool_discovery(
515    ///     "web-search",
516    ///     vec!["web-fetch".to_string(), "web-parse".to_string()]
517    /// );
518    /// ```
519    pub fn register_tool_discovery(&self, tool_name: &str, related_tools: Vec<String>) {
520        self.tool_discovery
521            .insert(tool_name.to_string(), related_tools);
522    }
523
524    /// Discovers related tools for a given tool name.
525    /// Returns the full Tool objects for all related tools that are registered.
526    /// Returns an empty Vec if no discovery relationships exist for this tool.
527    ///
528    /// # Arguments
529    /// * `tool_name` - The tool name to discover related tools for
530    ///
531    /// # Returns
532    /// * `Vec<Tool>` - List of related Tool objects (empty if none registered)
533    ///
534    /// # Example
535    /// ```ignore
536    /// let related_tools = pegboard.discover_tool("web-search");
537    /// // Returns Tool objects for "web-fetch" and "web-parse" if they're registered
538    /// ```
539    pub fn discover_tool(&self, tool_name: &str) -> Vec<Tool> {
540        self.tool_discovery
541            .get(tool_name)
542            .map(|entry| {
543                entry
544                    .value()
545                    .iter()
546                    .filter_map(|name| self.get_tool(name))
547                    .collect()
548            })
549            .unwrap_or_default()
550    }
551
552    /// Registers an MCP discovery relationship.
553    /// When `mcp_id` is used, the related MCPs can be discovered via `discover_mcp()`.
554    /// This replaces any existing discovery relationships for the MCP.
555    ///
556    /// # Arguments
557    /// * `mcp_id` - The MCP ID to register discovery for
558    /// * `related_mcps` - List of related MCP IDs that should be discovered when this MCP is used
559    ///
560    /// # Example
561    /// ```ignore
562    /// // When "web-mcp" is used, suggest "html-parser-mcp" and "image-fetcher-mcp"
563    /// pegboard.register_mcp_discovery(
564    ///     "web-mcp",
565    ///     vec!["html-parser-mcp".to_string(), "image-fetcher-mcp".to_string()]
566    /// );
567    /// ```
568    pub fn register_mcp_discovery(&self, mcp_id: &str, related_mcps: Vec<String>) {
569        self.mcp_discovery.insert(mcp_id.to_string(), related_mcps);
570    }
571
572    /// Discovers related MCP IDs for a given MCP ID.
573    /// Returns a list of related MCP IDs.
574    /// Returns an empty Vec if no discovery relationships exist for this MCP.
575    ///
576    /// # Arguments
577    /// * `mcp_id` - The MCP ID to discover related MCPs for
578    ///
579    /// # Returns
580    /// * `Vec<String>` - List of related MCP IDs (empty if none registered)
581    ///
582    /// # Example
583    /// ```ignore
584    /// let related_mcps = pegboard.discover_mcp("web-mcp");
585    /// // Returns ["html-parser-mcp", "image-fetcher-mcp"]
586    /// ```
587    pub fn discover_mcp(&self, mcp_id: &str) -> Vec<String> {
588        self.mcp_discovery
589            .get(mcp_id)
590            .map(|entry| entry.value().clone())
591            .unwrap_or_default()
592    }
593}
594
595/// PegBoard manages tool registration and their associated MCP services with namespace support.
596///
597/// ## Namespace and Tool Name Prefixing
598///
599/// When MCP services are registered with a namespace, their tool names are automatically
600/// prefixed to avoid conflicts. For example:
601///
602/// - Service "web_search" with tool "search" becomes "web_search::search"
603/// - Service "file_search" with tool "search" becomes "file_search::search"
604///
605/// The Tool's `name` field is modified to include the prefix, so when tools are sent
606/// to the LLM, they have unique names. The PegBoard maintains the mapping between
607/// prefixed names and original names for routing tool calls back to the correct service.
608///
609/// ## Thread Safety
610///
611/// PegBoard is designed for concurrent access and internally uses `Arc` for cheap cloning.
612/// It uses `DashMap` for all internal storage, providing lock-free concurrent access.
613/// All methods use `&self` and can be called concurrently from multiple threads/tasks.
614/// Simply clone the PegBoard to share it across async tasks.
615///
616/// ## Example
617///
618/// ```ignore
619/// let pegboard = PegBoard::new();
620/// let pegboard_clone = pegboard.clone(); // Cheap Arc clone
621/// ```
622#[derive(Clone)]
623pub struct PegBoard {
624    inner: Arc<InternalPegBoard>,
625}
626
627impl PegBoard {
628    /// Creates a new empty PegBoard
629    pub fn new() -> Self {
630        Self {
631            inner: Arc::new(InternalPegBoard::new()),
632        }
633    }
634
635    /// Registers a service and automatically discovers all its tools.
636    ///
637    /// See `InternalPegBoard::add_service` for full documentation.
638    pub async fn add_service(
639        &self,
640        mcp_id: String,
641        namespace: Option<String>,
642        service: RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
643    ) -> Result<(), PegBoardError> {
644        self.inner.add_service(mcp_id, namespace, service).await
645    }
646
647    /// Manually registers a tool with an optional namespace and service ID.
648    pub fn register_tool(
649        &self,
650        namespace: Option<&str>,
651        tool: Tool,
652        service_id: &str,
653    ) -> Result<(), PegBoardError> {
654        self.inner.register_tool(namespace, tool, service_id)
655    }
656
657    /// Gets a tool by its name (prefixed if registered with namespace, original otherwise)
658    pub fn get_tool(&self, tool_name: &str) -> Option<Tool> {
659        self.inner.get_tool(tool_name)
660    }
661
662    /// Selects multiple tools by their names.
663    pub fn select_tools(&self, tool_names: &[&str]) -> Option<Vec<Tool>> {
664        self.inner.select_tools(tool_names)
665    }
666
667    /// Gets routing information for a tool by its name
668    pub fn get_tool_route(&self, tool_name: &str) -> Option<(String, String)> {
669        self.inner.get_tool_route(tool_name)
670    }
671
672    /// Gets all tool names in a namespace (prefixed if namespace was used)
673    pub fn list_tools_in_namespace(&self, namespace: &str) -> Vec<String> {
674        self.inner.list_tools_in_namespace(namespace)
675    }
676
677    /// Gets all Tool objects in a namespace (with names as they appear to LLM)
678    pub fn get_tools_in_namespace(&self, namespace: &str) -> Vec<Tool> {
679        self.inner.get_tools_in_namespace(namespace)
680    }
681
682    /// Gets all registered tool names across all namespaces
683    pub fn list_all_tools(&self) -> Vec<String> {
684        self.inner.list_all_tools()
685    }
686
687    /// Gets all tools as a Vec
688    pub fn get_all_tools(&self) -> Vec<Tool> {
689        self.inner.get_all_tools()
690    }
691
692    /// Gets all registered namespaces
693    pub fn list_namespaces(&self) -> Vec<String> {
694        self.inner.list_namespaces()
695    }
696
697    /// Removes a tool by its prefixed name
698    pub fn unregister_tool(&self, prefixed_name: &str) -> Result<(), PegBoardError> {
699        self.inner.unregister_tool(prefixed_name)
700    }
701
702    /// Removes all tools in a namespace and the associated service
703    pub fn unregister_namespace(&self, namespace: &str) -> Result<usize, PegBoardError> {
704        self.inner.unregister_namespace(namespace)
705    }
706
707    /// Removes a service by its ID and all associated tools
708    pub fn unregister_service(&self, service_id: &str) -> Result<usize, PegBoardError> {
709        self.inner.unregister_service(service_id)
710    }
711
712    /// Returns the number of registered tools
713    pub fn tool_count(&self) -> usize {
714        self.inner.tool_count()
715    }
716
717    /// Returns the number of registered services
718    pub fn service_count(&self) -> usize {
719        self.inner.service_count()
720    }
721
722    /// Returns the number of registered namespaces
723    pub fn namespace_count(&self) -> usize {
724        self.inner.namespace_count()
725    }
726
727    /// Calls a tool by its name (as seen by the LLM) with the given arguments.
728    pub async fn call_tool(
729        &self,
730        tool_name: &str,
731        arguments: Value,
732    ) -> Result<CallToolResult, PegBoardError> {
733        self.inner.call_tool(tool_name, arguments).await
734    }
735
736    /// Registers a tool discovery relationship.
737    pub fn register_tool_discovery(&self, tool_name: &str, related_tools: Vec<String>) {
738        self.inner.register_tool_discovery(tool_name, related_tools)
739    }
740
741    /// Discovers related tools for a given tool name.
742    pub fn discover_tool(&self, tool_name: &str) -> Vec<Tool> {
743        self.inner.discover_tool(tool_name)
744    }
745
746    /// Registers an MCP discovery relationship.
747    pub fn register_mcp_discovery(&self, mcp_id: &str, related_mcps: Vec<String>) {
748        self.inner.register_mcp_discovery(mcp_id, related_mcps)
749    }
750
751    /// Discovers related MCP IDs for a given MCP ID.
752    pub fn discover_mcp(&self, mcp_id: &str) -> Vec<String> {
753        self.inner.discover_mcp(mcp_id)
754    }
755}
756
757impl Default for PegBoard {
758    fn default() -> Self {
759        Self::new()
760    }
761}
762
763/// Errors that can occur when working with PegBoard
764#[derive(Debug, thiserror::Error)]
765pub enum PegBoardError {
766    #[error("Tool '{0}' already exists")]
767    ToolAlreadyExists(String),
768
769    #[error("Tool '{0}' not found")]
770    ToolNotFound(String),
771
772    #[error("Invalid service ID '{0}'")]
773    InvalidServiceId(String),
774
775    #[error("MCP ID '{0}' already exists")]
776    DuplicateMcpId(String),
777
778    #[error("Namespace '{0}' already exists")]
779    NamespaceAlreadyExists(String),
780
781    #[error("Service error: {0}")]
782    ServiceError(String),
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use schemars::JsonSchema;
789
790    #[derive(JsonSchema)]
791    #[allow(dead_code)]
792    struct TestParams {
793        value: String,
794    }
795
796    #[tokio::test]
797    async fn registered_tool_calls_can_run_in_spawned_tasks() {
798        use rmcp::{
799            ErrorData, RoleServer, ServerHandler, ServiceExt,
800            model::{
801                CallToolResponse, ContentBlock, ListToolsResult, PaginatedRequestParams,
802                ServerCapabilities, ServerInfo,
803            },
804            service::RequestContext,
805        };
806
807        struct Echo;
808        impl ServerHandler for Echo {
809            fn get_info(&self) -> ServerInfo {
810                ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
811            }
812
813            async fn list_tools(
814                &self,
815                _: Option<PaginatedRequestParams>,
816                _: RequestContext<RoleServer>,
817            ) -> Result<ListToolsResult, ErrorData> {
818                Ok(ListToolsResult::with_all_items(vec![
819                    rmcp::model::Tool::new_with_raw(
820                        "echo",
821                        Some("Echo arguments".into()),
822                        Arc::new(Default::default()),
823                    ),
824                ]))
825            }
826
827            async fn call_tool(
828                &self,
829                request: CallToolRequestParams,
830                _: RequestContext<RoleServer>,
831            ) -> Result<CallToolResponse, ErrorData> {
832                assert_eq!(request.name, "echo");
833                Ok(CallToolResult::success(vec![ContentBlock::text(
834                    serde_json::to_string(&request.arguments.unwrap()).unwrap(),
835                )])
836                .into())
837            }
838        }
839
840        tokio::time::timeout(std::time::Duration::from_secs(5), async {
841            let (client_io, server_io) = tokio::io::duplex(4096);
842            let (client, server) =
843                tokio::join!(().into_dyn().serve(client_io), Echo.serve(server_io));
844            let server = server.unwrap();
845            let board = PegBoard::new();
846            board
847                .add_service("echo-service".into(), Some("test".into()), client.unwrap())
848                .await
849                .unwrap();
850
851            let task = tokio::spawn(async move {
852                board
853                    .call_tool("test::echo", serde_json::json!({"value": "hello"}))
854                    .await
855            });
856            let result = task.await.unwrap().unwrap();
857            let ContentBlock::Text(text) = &result.content[0] else {
858                panic!("expected a text tool result");
859            };
860            assert_eq!(
861                serde_json::from_str::<Value>(&text.text).unwrap(),
862                serde_json::json!({"value": "hello"})
863            );
864            server.cancel().await.unwrap();
865        })
866        .await
867        .expect("tool call should complete promptly");
868    }
869
870    #[test]
871    fn test_prefix_tool_name() {
872        let prefixed = prefix_tool_name("web_search", "search");
873        assert_eq!(prefixed, "web_search::search");
874
875        let prefixed2 = prefix_tool_name("fs", "read_file");
876        assert_eq!(prefixed2, "fs::read_file");
877    }
878
879    #[test]
880    fn test_pegboard_register_and_get() {
881        let pegboard = PegBoard::new();
882        let tool = crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
883
884        // Initially empty
885        assert_eq!(pegboard.tool_count(), 0);
886        assert_eq!(pegboard.namespace_count(), 0);
887
888        // Register fails with invalid service ID
889        assert!(
890            pegboard
891                .register_tool(Some("web"), tool.clone(), "invalid-service-id")
892                .is_err()
893        );
894
895        // Without namespace also fails with invalid service ID
896        assert!(
897            pegboard
898                .register_tool(None, tool.clone(), "invalid-service-id")
899                .is_err()
900        );
901    }
902
903    #[test]
904    fn test_pegboard_tool_name_prefixing() {
905        // Get tool with original name "search"
906        let original_tool =
907            crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
908        assert_eq!(original_tool.name, "search");
909
910        // After registration with namespace "web", the name should be "web-search"
911        // But we can't test this without a valid service_idx
912        // The prefixing logic is tested in test_prefix_tool_name
913    }
914
915    #[test]
916    fn test_pegboard_namespace_operations() {
917        let pegboard = PegBoard::new();
918
919        // Initially empty
920        assert_eq!(pegboard.list_namespaces().len(), 0);
921        assert_eq!(pegboard.list_all_tools().len(), 0);
922        assert_eq!(pegboard.get_all_tools().len(), 0);
923
924        // List tools in non-existent namespace returns empty
925        assert_eq!(pegboard.list_tools_in_namespace("nonexistent").len(), 0);
926        assert_eq!(pegboard.get_tools_in_namespace("nonexistent").len(), 0);
927    }
928
929    #[test]
930    fn test_pegboard_get_tool_methods() {
931        let pegboard = PegBoard::new();
932
933        // Get non-existent tool returns None (using prefixed name)
934        assert!(pegboard.get_tool("web-search").is_none());
935        assert!(pegboard.get_tool_route("web-search").is_none());
936    }
937
938    #[test]
939    fn test_pegboard_unregister() {
940        let pegboard = PegBoard::new();
941
942        // Unregister non-existent tool should fail (using prefixed name)
943        assert!(pegboard.unregister_tool("web-nonexistent").is_err());
944
945        // Unregister non-existent namespace
946        let result = pegboard.unregister_namespace("nonexistent");
947        assert!(result.is_ok());
948        assert_eq!(result.unwrap(), 0); // 0 tools removed
949    }
950
951    #[test]
952    fn test_tool_route_structure() {
953        let route = ToolRoute {
954            service_id: "test-mcp".to_string(),
955            original_name: "search".to_string(),
956        };
957
958        assert_eq!(route.service_id, "test-mcp");
959        assert_eq!(route.original_name, "search");
960    }
961
962    #[test]
963    fn test_optional_namespace() {
964        // Test that None and Some("") both mean no namespace
965        let namespace_none: Option<String> = None;
966        let namespace_empty = Some(String::new());
967
968        // Both should normalize to empty string
969        let ns1 = namespace_none.unwrap_or_default();
970        let ns2 = namespace_empty.unwrap_or_default();
971
972        assert_eq!(ns1, "");
973        assert_eq!(ns2, "");
974        assert!(!ns1.is_empty() == false);
975        assert!(!ns2.is_empty() == false);
976    }
977
978    #[test]
979    fn test_prefix_only_when_namespace_provided() {
980        let original_name = "search";
981
982        // With namespace - should prefix
983        let with_ns = prefix_tool_name("web", original_name);
984        assert_eq!(with_ns, "web::search");
985
986        // Without namespace - would use original (tested via add_service logic)
987        // The prefix_tool_name function always prefixes, but add_service checks has_namespace first
988    }
989
990    #[test]
991    fn test_tool_discovery() {
992        let pegboard = PegBoard::new();
993
994        // Initially, discovering a non-existent tool returns empty
995        let discovered = pegboard.discover_tool("web-search");
996        assert_eq!(discovered.len(), 0);
997
998        // Register discovery relationship
999        pegboard.register_tool_discovery(
1000            "web-search",
1001            vec!["web-fetch".to_string(), "web-parse".to_string()],
1002        );
1003
1004        // Discover should return empty if related tools don't exist
1005        let discovered = pegboard.discover_tool("web-search");
1006        assert_eq!(discovered.len(), 0);
1007
1008        // Replace discovery relationship
1009        pegboard.register_tool_discovery("web-search", vec!["other-tool".to_string()]);
1010
1011        // Discovery is replaced (not appended)
1012        let discovered = pegboard.discover_tool("web-search");
1013        assert_eq!(discovered.len(), 0); // Still empty since tools don't exist
1014    }
1015
1016    #[test]
1017    fn test_mcp_discovery() {
1018        let pegboard = PegBoard::new();
1019
1020        // Initially, discovering a non-existent MCP returns empty
1021        let discovered = pegboard.discover_mcp("web-mcp");
1022        assert_eq!(discovered.len(), 0);
1023
1024        // Register MCP discovery relationship
1025        pegboard.register_mcp_discovery(
1026            "web-mcp",
1027            vec![
1028                "html-parser-mcp".to_string(),
1029                "image-fetcher-mcp".to_string(),
1030            ],
1031        );
1032
1033        // Discover returns the registered related MCPs
1034        let discovered = pegboard.discover_mcp("web-mcp");
1035        assert_eq!(discovered.len(), 2);
1036        assert!(discovered.contains(&"html-parser-mcp".to_string()));
1037        assert!(discovered.contains(&"image-fetcher-mcp".to_string()));
1038
1039        // Replace MCP discovery relationship
1040        pegboard.register_mcp_discovery("web-mcp", vec!["other-mcp".to_string()]);
1041
1042        // Discovery is replaced (not appended)
1043        let discovered = pegboard.discover_mcp("web-mcp");
1044        assert_eq!(discovered.len(), 1);
1045        assert_eq!(discovered[0], "other-mcp");
1046    }
1047}