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