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