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;
9
10use crate::Tool;
11
12/// PegBoard manages tool registration and their associated MCP services with namespace support.
13///
14/// ## Namespace and Tool Name Prefixing
15///
16/// When MCP services are registered with a namespace, their tool names are automatically
17/// prefixed to avoid conflicts. For example:
18///
19/// - Service "web_search" with tool "search" becomes "web_search-search"
20/// - Service "file_search" with tool "search" becomes "file_search-search"
21///
22/// The Tool's `name` field is modified to include the prefix, so when tools are sent
23/// to the LLM, they have unique names. The PegBoard maintains the mapping between
24/// prefixed names and original names for routing tool calls back to the correct service.
25///
26/// ## Thread Safety
27///
28/// PegBoard is designed for concurrent access. All read methods use `&self` and can be
29/// called concurrently from multiple threads/tasks. For tokio usage, wrap in `Arc<PegBoard>`.
30/// See `TOKIO_USAGE.md` for detailed examples.
31pub struct PegBoard {
32 /// All registered tools with prefixed names (e.g., "namespace-tool_name")
33 /// These tools have their `name` field modified to include the prefix
34 tools: DashMap<String, Tool>,
35
36 /// MCP services with their namespaces: Vec<(namespace, service)>
37 services: Vec<(
38 String,
39 RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
40 )>,
41
42 /// Maps prefixed tool name to (service_index, original_tool_name)
43 /// Used for routing: when LLM calls "namespace-search", we look up which service
44 /// and what the original tool name was
45 tool_routing: DashMap<String, ToolRoute>,
46
47 /// Maps namespace to list of prefixed tool names in that namespace
48 namespace_tools: DashMap<String, Vec<String>>,
49}
50
51/// Routing information for a tool
52#[derive(Debug, Clone)]
53struct ToolRoute {
54 /// Index of the service that provides this tool
55 service_index: usize,
56 /// Original tool name (before prefixing)
57 original_name: String,
58}
59
60/// Helper to create a prefixed tool name
61fn prefix_tool_name(namespace: &str, tool_name: &str) -> String {
62 format!("{}-{}", namespace, tool_name)
63}
64
65impl PegBoard {
66 /// Creates a new empty PegBoard
67 pub fn new() -> Self {
68 Self {
69 tools: DashMap::new(),
70 services: Vec::new(),
71 tool_routing: DashMap::new(),
72 namespace_tools: DashMap::new(),
73 }
74 }
75
76 /// Registers a service and automatically discovers all its tools.
77 ///
78 /// This method:
79 /// 1. Calls `list_tools()` on the service to discover all available tools
80 /// 2. Converts tools from rmcp format to PegBoard's Tool format
81 /// 3. If namespace is provided, prefixes each tool name (e.g., "namespace-tool_name")
82 /// 4. If namespace is None or empty, uses original tool names (no prefixing)
83 /// 5. Adds the service to the registry
84 /// 6. Registers all tools for use with LLM
85 ///
86 /// # Arguments
87 /// * `namespace` - Optional namespace prefix. Use None or empty string if no conflicts expected
88 /// * `service` - The MCP service to register
89 ///
90 /// # Returns
91 /// * Number of tools discovered and registered
92 ///
93 /// # Example
94 /// ```ignore
95 /// // With namespace (prefixing enabled)
96 /// pegboard.add_service(Some("web".to_string()), service).await?;
97 /// // Tool "search" is now available as "web-search"
98 ///
99 /// // Without namespace (no prefixing)
100 /// pegboard.add_service(None, service).await?;
101 /// // Tool "search" keeps its original name "search"
102 /// ```
103 pub async fn add_service(
104 &mut self,
105 namespace: Option<String>,
106 service: RunningService<RoleClient, Box<dyn DynService<RoleClient>>>,
107 ) -> Result<usize, PegBoardError> {
108 // Normalize namespace: None or empty string means no namespace
109 let namespace_str = namespace.unwrap_or_default();
110 let has_namespace = !namespace_str.is_empty();
111
112 // Check if namespace already exists (only if we have one)
113 if has_namespace && self.namespace_tools.contains_key(&namespace_str) {
114 return Err(PegBoardError::NamespaceAlreadyExists(namespace_str));
115 }
116
117 // Get the service index before we consume the service
118 let service_idx = self.services.len();
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.push((namespace_str.clone(), service));
139
140 // Track tool names
141 let mut registered_tool_names = Vec::new();
142
143 // Register each tool
144 for original_tool in tools_list {
145 let original_name = original_tool.name.to_string();
146
147 // Prefix name only if namespace is provided
148 let final_name = if has_namespace {
149 prefix_tool_name(&namespace_str, &original_name)
150 } else {
151 original_name.clone()
152 };
153
154 // Check if this tool name already exists
155 if self.tools.contains_key(&final_name) {
156 return Err(PegBoardError::ToolAlreadyExists(final_name));
157 }
158
159 // Create a tool with the final name (prefixed or original)
160 let mut final_tool = original_tool.clone();
161 final_tool.name = Cow::Owned(final_name.clone());
162
163 // Register the tool
164 self.tools.insert(final_name.clone(), final_tool);
165
166 // Store routing information (service index + original name)
167 self.tool_routing.insert(
168 final_name.clone(),
169 ToolRoute {
170 service_index: service_idx,
171 original_name,
172 },
173 );
174
175 registered_tool_names.push(final_name);
176 }
177
178 let tool_count = registered_tool_names.len();
179
180 // Only track namespace if we have one
181 if has_namespace {
182 self.namespace_tools
183 .insert(namespace_str, registered_tool_names);
184 }
185
186 Ok(tool_count)
187 }
188
189 /// Manually registers a tool with an optional namespace and service index.
190 /// If namespace is provided, the tool name will be prefixed.
191 /// If namespace is None or empty, the original tool name is used.
192 /// Prefer `add_service()` for automatic tool discovery.
193 pub fn register_tool(
194 &self,
195 namespace: Option<&str>,
196 tool: Tool,
197 service_idx: usize,
198 ) -> Result<(), PegBoardError> {
199 // Check if service index is valid
200 if service_idx >= self.services.len() {
201 return Err(PegBoardError::InvalidServiceIndex {
202 index: service_idx,
203 max: self.services.len(),
204 });
205 }
206
207 let original_name = tool.name.to_string();
208 let namespace_str = namespace.unwrap_or("");
209 let has_namespace = !namespace_str.is_empty();
210
211 // Prefix name only if namespace is provided
212 let final_name = if has_namespace {
213 prefix_tool_name(namespace_str, &original_name)
214 } else {
215 original_name.clone()
216 };
217
218 // Check if tool already exists
219 if self.tools.contains_key(&final_name) {
220 return Err(PegBoardError::ToolAlreadyExists(final_name));
221 }
222
223 // Create tool with final name (prefixed or original)
224 let mut final_tool = tool;
225 final_tool.name = Cow::Owned(final_name.clone());
226
227 // Register tool and routing
228 self.tools.insert(final_name.clone(), final_tool);
229 self.tool_routing.insert(
230 final_name.clone(),
231 ToolRoute {
232 service_index: service_idx,
233 original_name,
234 },
235 );
236
237 // Update namespace tracking (only if we have a namespace)
238 if has_namespace {
239 self.namespace_tools
240 .entry(namespace_str.to_string())
241 .or_default()
242 .push(final_name);
243 }
244
245 Ok(())
246 }
247
248 /// Gets a tool by its name (prefixed if registered with namespace, original otherwise)
249 /// This is the name that the LLM sees and uses
250 pub fn get_tool(&self, tool_name: &str) -> Option<Tool> {
251 self.tools.get(tool_name).map(|entry| entry.value().clone())
252 }
253
254 /// Selects multiple tools by their names.
255 /// Returns `Some(Vec<Tool>)` if ALL requested tools are found.
256 /// Returns `None` if ANY tool is missing.
257 ///
258 /// # Arguments
259 /// * `tool_names` - A slice of tool names (prefixed if registered with namespace)
260 ///
261 /// # Returns
262 /// * `Some(Vec<Tool>)` if all tools exist
263 /// * `None` if any tool is missing
264 ///
265 /// # Example
266 /// ```ignore
267 /// let tools = pegboard.select_tools(&["web-search", "file-read"]);
268 /// if let Some(tools) = tools {
269 /// // All tools found, can use them
270 /// } else {
271 /// // One or more tools not found
272 /// }
273 /// ```
274 pub fn select_tools(&self, tool_names: &[&str]) -> Option<Vec<Tool>> {
275 let mut result = Vec::with_capacity(tool_names.len());
276
277 for &tool_name in tool_names {
278 let tool = self.get_tool(tool_name)?;
279 result.push(tool);
280 }
281
282 Some(result)
283 }
284
285 /// Gets routing information for a tool by its name
286 /// Returns (service_index, original_tool_name) for routing the call
287 pub fn get_tool_route(&self, tool_name: &str) -> Option<(usize, String)> {
288 self.tool_routing.get(tool_name).map(|entry| {
289 let route = entry.value();
290 (route.service_index, route.original_name.clone())
291 })
292 }
293
294 /// Gets all tool names in a namespace (prefixed if namespace was used)
295 pub fn list_tools_in_namespace(&self, namespace: &str) -> Vec<String> {
296 self.namespace_tools
297 .get(namespace)
298 .map(|entry| entry.value().clone())
299 .unwrap_or_default()
300 }
301
302 /// Gets all Tool objects in a namespace (with names as they appear to LLM)
303 pub fn get_tools_in_namespace(&self, namespace: &str) -> Vec<Tool> {
304 self.list_tools_in_namespace(namespace)
305 .iter()
306 .filter_map(|tool_name| self.get_tool(tool_name))
307 .collect()
308 }
309
310 /// Gets all registered tool names across all namespaces
311 /// These are the names that should be sent to the LLM
312 pub fn list_all_tools(&self) -> Vec<String> {
313 self.tools.iter().map(|entry| entry.key().clone()).collect()
314 }
315
316 /// Gets all tools as a Vec
317 /// These are the tools that should be sent to the LLM
318 pub fn get_all_tools(&self) -> Vec<Tool> {
319 self.tools
320 .iter()
321 .map(|entry| entry.value().clone())
322 .collect()
323 }
324
325 /// Gets all registered namespaces
326 pub fn list_namespaces(&self) -> Vec<String> {
327 self.namespace_tools
328 .iter()
329 .map(|entry| entry.key().clone())
330 .collect()
331 }
332
333 /// Removes a tool by its prefixed name
334 pub fn unregister_tool(&self, prefixed_name: &str) -> Result<(), PegBoardError> {
335 if self.tools.remove(prefixed_name).is_none() {
336 return Err(PegBoardError::ToolNotFound(prefixed_name.to_string()));
337 }
338
339 self.tool_routing.remove(prefixed_name);
340
341 // Remove from namespace tracking
342 // We need to find which namespace this tool belongs to
343 for mut namespace_entry in self.namespace_tools.iter_mut() {
344 namespace_entry.value_mut().retain(|n| n != prefixed_name);
345 }
346
347 Ok(())
348 }
349
350 /// Removes all tools in a namespace (when removing a service)
351 pub fn unregister_namespace(&self, namespace: &str) -> Result<usize, PegBoardError> {
352 let prefixed_names = self.list_tools_in_namespace(namespace);
353 let count = prefixed_names.len();
354
355 for prefixed_name in prefixed_names {
356 self.tools.remove(&prefixed_name);
357 self.tool_routing.remove(&prefixed_name);
358 }
359
360 self.namespace_tools.remove(namespace);
361 Ok(count)
362 }
363
364 /// Returns the number of registered tools
365 pub fn tool_count(&self) -> usize {
366 self.tools.len()
367 }
368
369 /// Returns the number of registered services
370 pub fn service_count(&self) -> usize {
371 self.services.len()
372 }
373
374 /// Returns the number of registered namespaces
375 pub fn namespace_count(&self) -> usize {
376 self.namespace_tools.len()
377 }
378
379 /// Calls a tool by its name (as seen by the LLM) with the given arguments.
380 ///
381 /// This method:
382 /// 1. Looks up the routing information using the tool name
383 /// 2. Finds the service that provides this tool
384 /// 3. Calls the service's `call_tool` method with the original tool name
385 ///
386 /// # Arguments
387 /// * `tool_name` - The tool name as seen by the LLM (prefixed if namespace was used)
388 /// * `arguments` - The arguments to pass to the tool as a JSON value
389 ///
390 /// # Returns
391 /// * `CallToolResult` containing the tool's response
392 ///
393 /// # Errors
394 /// * `PegBoardError::ToolNotFound` - If the tool name is not registered
395 /// * `PegBoardError::ServiceError` - If the service call fails
396 ///
397 /// # Example
398 /// ```ignore
399 /// // LLM calls "web-search"
400 /// let result = pegboard.call_tool(
401 /// "web-search",
402 /// serde_json::json!({"query": "rust programming"}),
403 /// ).await?;
404 /// ```
405 pub async fn call_tool(
406 &self,
407 tool_name: &str,
408 arguments: Value,
409 ) -> Result<CallToolResult, PegBoardError> {
410 // Get routing information
411 let (service_idx, original_name) = self
412 .get_tool_route(tool_name)
413 .ok_or_else(|| PegBoardError::ToolNotFound(tool_name.to_string()))?;
414
415 // Get the service
416 let (_namespace, service) =
417 self.services
418 .get(service_idx)
419 .ok_or(PegBoardError::InvalidServiceIndex {
420 index: service_idx,
421 max: self.services.len(),
422 })?;
423
424 // Convert arguments to JsonObject if it's an object, otherwise use None
425 let arguments_obj = match arguments {
426 Value::Object(obj) => Some(obj),
427 Value::Null => None,
428 _ => {
429 return Err(PegBoardError::ServiceError(
430 "Tool arguments must be a JSON object or null".to_string(),
431 ));
432 }
433 };
434
435 // Call the service's call_tool method with the original tool name
436 let param = CallToolRequestParam {
437 name: Cow::from(original_name),
438 arguments: arguments_obj,
439 };
440
441 service
442 .call_tool(param)
443 .await
444 .map_err(|e| PegBoardError::ServiceError(format!("Tool call failed: {:?}", e)))
445 }
446}
447
448impl Default for PegBoard {
449 fn default() -> Self {
450 Self::new()
451 }
452}
453
454/// Errors that can occur when working with PegBoard
455#[derive(Debug, thiserror::Error)]
456pub enum PegBoardError {
457 #[error("Tool '{0}' already exists")]
458 ToolAlreadyExists(String),
459
460 #[error("Tool '{0}' not found")]
461 ToolNotFound(String),
462
463 #[error("Invalid service index {index}, max is {max}")]
464 InvalidServiceIndex { index: usize, max: usize },
465
466 #[error("Namespace '{0}' already exists")]
467 NamespaceAlreadyExists(String),
468
469 #[error("Service error: {0}")]
470 ServiceError(String),
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use schemars::JsonSchema;
477
478 #[derive(JsonSchema)]
479 #[allow(dead_code)]
480 struct TestParams {
481 value: String,
482 }
483
484 #[test]
485 fn test_prefix_tool_name() {
486 let prefixed = prefix_tool_name("web_search", "search");
487 assert_eq!(prefixed, "web_search-search");
488
489 let prefixed2 = prefix_tool_name("fs", "read_file");
490 assert_eq!(prefixed2, "fs-read_file");
491 }
492
493 #[test]
494 fn test_pegboard_register_and_get() {
495 let pegboard = PegBoard::new();
496 let tool = crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
497
498 // Initially empty
499 assert_eq!(pegboard.tool_count(), 0);
500 assert_eq!(pegboard.namespace_count(), 0);
501
502 // Register fails with invalid service index
503 assert!(
504 pegboard
505 .register_tool(Some("web"), tool.clone(), 0)
506 .is_err()
507 );
508
509 // Without namespace also fails with invalid service index
510 assert!(pegboard.register_tool(None, tool.clone(), 0).is_err());
511 }
512
513 #[test]
514 fn test_pegboard_tool_name_prefixing() {
515 // Get tool with original name "search"
516 let original_tool =
517 crate::get_tool::<TestParams, _, _>("search", Some("Search tool")).unwrap();
518 assert_eq!(original_tool.name, "search");
519
520 // After registration with namespace "web", the name should be "web-search"
521 // But we can't test this without a valid service_idx
522 // The prefixing logic is tested in test_prefix_tool_name
523 }
524
525 #[test]
526 fn test_pegboard_namespace_operations() {
527 let pegboard = PegBoard::new();
528
529 // Initially empty
530 assert_eq!(pegboard.list_namespaces().len(), 0);
531 assert_eq!(pegboard.list_all_tools().len(), 0);
532 assert_eq!(pegboard.get_all_tools().len(), 0);
533
534 // List tools in non-existent namespace returns empty
535 assert_eq!(pegboard.list_tools_in_namespace("nonexistent").len(), 0);
536 assert_eq!(pegboard.get_tools_in_namespace("nonexistent").len(), 0);
537 }
538
539 #[test]
540 fn test_pegboard_get_tool_methods() {
541 let pegboard = PegBoard::new();
542
543 // Get non-existent tool returns None (using prefixed name)
544 assert!(pegboard.get_tool("web-search").is_none());
545 assert!(pegboard.get_tool_route("web-search").is_none());
546 }
547
548 #[test]
549 fn test_pegboard_unregister() {
550 let pegboard = PegBoard::new();
551
552 // Unregister non-existent tool should fail (using prefixed name)
553 assert!(pegboard.unregister_tool("web-nonexistent").is_err());
554
555 // Unregister non-existent namespace
556 let result = pegboard.unregister_namespace("nonexistent");
557 assert!(result.is_ok());
558 assert_eq!(result.unwrap(), 0); // 0 tools removed
559 }
560
561 #[test]
562 fn test_tool_route_structure() {
563 let route = ToolRoute {
564 service_index: 0,
565 original_name: "search".to_string(),
566 };
567
568 assert_eq!(route.service_index, 0);
569 assert_eq!(route.original_name, "search");
570 }
571
572 #[test]
573 fn test_optional_namespace() {
574 // Test that None and Some("") both mean no namespace
575 let namespace_none: Option<String> = None;
576 let namespace_empty = Some(String::new());
577
578 // Both should normalize to empty string
579 let ns1 = namespace_none.unwrap_or_default();
580 let ns2 = namespace_empty.unwrap_or_default();
581
582 assert_eq!(ns1, "");
583 assert_eq!(ns2, "");
584 assert!(!ns1.is_empty() == false);
585 assert!(!ns2.is_empty() == false);
586 }
587
588 #[test]
589 fn test_prefix_only_when_namespace_provided() {
590 let original_name = "search";
591
592 // With namespace - should prefix
593 let with_ns = prefix_tool_name("web", original_name);
594 assert_eq!(with_ns, "web-search");
595
596 // Without namespace - would use original (tested via add_service logic)
597 // The prefix_tool_name function always prefixes, but add_service checks has_namespace first
598 }
599
600 // Note: Integration test for add_service() will be added once we have a proper
601 // way to create RunningService instances for testing. For now, the logic is
602 // validated through unit tests of individual components.
603}