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