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