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