1use std::collections::HashMap;
10use std::sync::Arc;
11
12use crate::tools::mcp_client::McpRegistry;
13use crate::tools::mcp_protocol::McpToolDef;
14use crate::tools::mcp_tool::McpToolWrapper;
15use crate::tools::traits::{Tool, ToolSpec};
16
17#[derive(Debug, Clone)]
23pub struct DeferredMcpToolStub {
24 pub prefixed_name: String,
26 pub description: String,
28 def: McpToolDef,
30}
31
32impl DeferredMcpToolStub {
33 pub fn new(prefixed_name: String, def: McpToolDef) -> Self {
34 let description = def
35 .description
36 .clone()
37 .unwrap_or_else(|| "MCP tool".to_string());
38 Self {
39 prefixed_name,
40 description,
41 def,
42 }
43 }
44
45 pub fn activate(&self, registry: Arc<McpRegistry>) -> McpToolWrapper {
47 McpToolWrapper::new(self.prefixed_name.clone(), self.def.clone(), registry)
48 }
49}
50
51#[derive(Clone)]
56pub struct DeferredMcpToolSet {
57 pub stubs: Vec<DeferredMcpToolStub>,
59 pub registry: Arc<McpRegistry>,
61}
62
63impl DeferredMcpToolSet {
64 pub async fn from_registry(registry: Arc<McpRegistry>) -> Self {
66 let names = registry.tool_names();
67 let mut stubs = Vec::with_capacity(names.len());
68 for name in names {
69 if let Some(def) = registry.get_tool_def(&name).await {
70 stubs.push(DeferredMcpToolStub::new(name, def));
71 }
72 }
73 Self { stubs, registry }
74 }
75
76 pub async fn from_registry_filtered<F>(registry: Arc<McpRegistry>, filter: F) -> Self
79 where
80 F: Fn(&str) -> bool,
81 {
82 let names = registry.tool_names();
83 let mut stubs = Vec::with_capacity(names.len());
84 for name in names {
85 if !filter(&name) {
86 continue;
87 }
88 if let Some(def) = registry.get_tool_def(&name).await {
89 stubs.push(DeferredMcpToolStub::new(name, def));
90 }
91 }
92 Self { stubs, registry }
93 }
94
95 pub fn stub_names(&self) -> Vec<&str> {
97 self.stubs
98 .iter()
99 .map(|s| s.prefixed_name.as_str())
100 .collect()
101 }
102
103 pub fn len(&self) -> usize {
105 self.stubs.len()
106 }
107
108 pub fn is_empty(&self) -> bool {
110 self.stubs.is_empty()
111 }
112
113 pub fn get_by_name(&self, name: &str) -> Option<&DeferredMcpToolStub> {
119 if let Some(stub) = self.stubs.iter().find(|s| s.prefixed_name == name) {
121 return Some(stub);
122 }
123 if name.contains("__") {
125 return None;
126 }
127 let mut resolved: Option<&DeferredMcpToolStub> = None;
129 for stub in &self.stubs {
130 let Some((_, suffix)) = stub.prefixed_name.split_once("__") else {
131 continue;
132 };
133 if suffix != name {
134 continue;
135 }
136 if resolved.is_some() {
137 return None;
139 }
140 resolved = Some(stub);
141 }
142 resolved
143 }
144
145 pub fn search(&self, query: &str, max_results: usize) -> Vec<&DeferredMcpToolStub> {
149 let terms: Vec<String> = query
150 .split_whitespace()
151 .map(|t| t.to_ascii_lowercase())
152 .collect();
153 if terms.is_empty() {
154 return self.stubs.iter().take(max_results).collect();
155 }
156
157 let mut scored: Vec<(&DeferredMcpToolStub, usize)> = self
158 .stubs
159 .iter()
160 .filter_map(|stub| {
161 let haystack = format!(
162 "{} {}",
163 stub.prefixed_name.to_ascii_lowercase(),
164 stub.description.to_ascii_lowercase()
165 );
166 let hits = terms
167 .iter()
168 .filter(|t| haystack.contains(t.as_str()))
169 .count();
170 if hits > 0 { Some((stub, hits)) } else { None }
171 })
172 .collect();
173
174 scored.sort_by(|a, b| b.1.cmp(&a.1));
175 scored
176 .into_iter()
177 .take(max_results)
178 .map(|(s, _)| s)
179 .collect()
180 }
181
182 pub fn activate(&self, name: &str) -> Option<Box<dyn Tool>> {
184 self.get_by_name(name).map(|stub| {
185 let wrapper = stub.activate(Arc::clone(&self.registry));
186 Box::new(wrapper) as Box<dyn Tool>
187 })
188 }
189
190 pub fn tool_spec(&self, name: &str) -> Option<ToolSpec> {
192 self.get_by_name(name).map(|stub| {
193 let wrapper = stub.activate(Arc::clone(&self.registry));
194 wrapper.spec()
195 })
196 }
197}
198
199pub struct ActivatedToolSet {
206 tools: HashMap<String, Arc<dyn Tool>>,
207}
208
209impl ActivatedToolSet {
210 pub fn new() -> Self {
211 Self {
212 tools: HashMap::new(),
213 }
214 }
215
216 pub fn activate(&mut self, name: String, tool: Arc<dyn Tool>) {
217 self.tools.insert(name, tool);
218 }
219
220 pub fn is_activated(&self, name: &str) -> bool {
221 self.tools.contains_key(name)
222 }
223
224 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
226 self.tools.get(name).cloned()
227 }
228
229 pub fn get_resolved(&self, name: &str) -> Option<Arc<dyn Tool>> {
235 if let Some(tool) = self.get(name) {
236 return Some(tool);
237 }
238 if name.contains("__") {
239 return None;
240 }
241
242 let mut resolved = None;
243 for (tool_name, tool) in &self.tools {
244 let Some((_, suffix)) = tool_name.split_once("__") else {
245 continue;
246 };
247 if suffix != name {
248 continue;
249 }
250 if resolved.is_some() {
251 return None;
252 }
253 resolved = Some(Arc::clone(tool));
254 }
255
256 resolved
257 }
258
259 pub fn tool_specs(&self) -> Vec<ToolSpec> {
260 self.tools.values().map(|t| t.spec()).collect()
261 }
262
263 pub fn tool_names(&self) -> Vec<&str> {
264 self.tools.keys().map(|s| s.as_str()).collect()
265 }
266}
267
268impl Default for ActivatedToolSet {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274pub const LOCAL_MODEL_EAGER_SUFFIXES: &[&str] = &[
283 "create_agent",
285 "wait_for_agent",
286 "send_agent_prompt",
287 "get_agent_activity",
288 "list_agents",
289 "cancel_agent",
290 "resolve_outcome",
292 "get_workflow_context",
294 "save_plan",
296 "recall_plans",
297 "compact_conversation",
299];
300
301pub fn is_local_model_eager_tool(prefixed_name: &str) -> bool {
305 LOCAL_MODEL_EAGER_SUFFIXES
306 .iter()
307 .any(|suffix| prefixed_name.ends_with(&format!("__{suffix}")))
308}
309
310pub const OPERATOR_MEMORY_REFLEX_TOOLS: &[&str] = &[
314 "kumiho-memory__kumiho_memory_engage",
315 "kumiho-memory__kumiho_memory_reflect",
316];
317
318pub fn is_operator_seat_eager_tool(prefixed_name: &str) -> bool {
324 is_local_model_eager_tool(prefixed_name)
325 || OPERATOR_MEMORY_REFLEX_TOOLS
326 .iter()
327 .any(|n| *n == prefixed_name)
328}
329
330pub fn build_deferred_tools_section(deferred: &DeferredMcpToolSet) -> String {
337 if deferred.is_empty() {
338 return String::new();
339 }
340 let mut out = String::new();
341 out.push_str("## Deferred Tools\n\n");
342 out.push_str(
343 "The tools listed below are available but NOT yet loaded. \
344 To use any of them you MUST first call the `tool_search` tool \
345 to fetch their full schemas. Use `\"select:name1,name2\"` for \
346 exact tools or keywords to search. Once activated, the tools \
347 become callable for the rest of the conversation.\n\n",
348 );
349 out.push_str("<available-deferred-tools>\n");
350 for stub in &deferred.stubs {
351 out.push_str(&stub.prefixed_name);
352 out.push_str(" - ");
353 out.push_str(&stub.description);
354 out.push('\n');
355 }
356 out.push_str("</available-deferred-tools>\n");
357 out
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn make_stub(name: &str, desc: &str) -> DeferredMcpToolStub {
365 let def = McpToolDef {
366 name: name.to_string(),
367 description: Some(desc.to_string()),
368 input_schema: serde_json::json!({"type": "object", "properties": {}}),
369 };
370 DeferredMcpToolStub::new(name.to_string(), def)
371 }
372
373 #[test]
374 fn stub_uses_description_from_def() {
375 let stub = make_stub("fs__read", "Read a file");
376 assert_eq!(stub.description, "Read a file");
377 }
378
379 #[test]
380 fn stub_defaults_description_when_none() {
381 let def = McpToolDef {
382 name: "mystery".into(),
383 description: None,
384 input_schema: serde_json::json!({}),
385 };
386 let stub = DeferredMcpToolStub::new("srv__mystery".into(), def);
387 assert_eq!(stub.description, "MCP tool");
388 }
389
390 #[test]
391 fn activated_set_tracks_activation() {
392 use crate::tools::traits::ToolResult;
393 use async_trait::async_trait;
394
395 struct FakeTool;
396 #[async_trait]
397 impl Tool for FakeTool {
398 fn name(&self) -> &str {
399 "fake"
400 }
401 fn description(&self) -> &str {
402 "fake tool"
403 }
404 fn parameters_schema(&self) -> serde_json::Value {
405 serde_json::json!({})
406 }
407 async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
408 Ok(ToolResult {
409 success: true,
410 output: String::new(),
411 error: None,
412 })
413 }
414 }
415
416 let mut set = ActivatedToolSet::new();
417 assert!(!set.is_activated("fake"));
418 set.activate("fake".into(), Arc::new(FakeTool));
419 assert!(set.is_activated("fake"));
420 assert!(set.get("fake").is_some());
421 assert_eq!(set.tool_specs().len(), 1);
422 }
423
424 #[test]
425 fn activated_set_resolves_unique_suffix() {
426 use crate::tools::traits::ToolResult;
427 use async_trait::async_trait;
428
429 struct FakeTool;
430 #[async_trait]
431 impl Tool for FakeTool {
432 fn name(&self) -> &str {
433 "docker-mcp__extract_text"
434 }
435 fn description(&self) -> &str {
436 "fake tool"
437 }
438 fn parameters_schema(&self) -> serde_json::Value {
439 serde_json::json!({})
440 }
441 async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
442 Ok(ToolResult {
443 success: true,
444 output: String::new(),
445 error: None,
446 })
447 }
448 }
449
450 let mut set = ActivatedToolSet::new();
451 set.activate("docker-mcp__extract_text".into(), Arc::new(FakeTool));
452 assert!(set.get_resolved("extract_text").is_some());
453 }
454
455 #[test]
456 fn activated_set_rejects_ambiguous_suffix() {
457 use crate::tools::traits::ToolResult;
458 use async_trait::async_trait;
459
460 struct FakeTool(&'static str);
461 #[async_trait]
462 impl Tool for FakeTool {
463 fn name(&self) -> &str {
464 self.0
465 }
466 fn description(&self) -> &str {
467 "fake tool"
468 }
469 fn parameters_schema(&self) -> serde_json::Value {
470 serde_json::json!({})
471 }
472 async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
473 Ok(ToolResult {
474 success: true,
475 output: String::new(),
476 error: None,
477 })
478 }
479 }
480
481 let mut set = ActivatedToolSet::new();
482 set.activate(
483 "docker-mcp__extract_text".into(),
484 Arc::new(FakeTool("docker-mcp__extract_text")),
485 );
486 set.activate(
487 "ocr-mcp__extract_text".into(),
488 Arc::new(FakeTool("ocr-mcp__extract_text")),
489 );
490 assert!(set.get_resolved("extract_text").is_none());
491 }
492
493 #[test]
494 fn build_deferred_section_empty_when_no_stubs() {
495 let set = DeferredMcpToolSet {
496 stubs: vec![],
497 registry: std::sync::Arc::new(
498 tokio::runtime::Runtime::new()
499 .unwrap()
500 .block_on(McpRegistry::connect_all(&[]))
501 .unwrap(),
502 ),
503 };
504 assert!(build_deferred_tools_section(&set).is_empty());
505 }
506
507 #[test]
508 fn build_deferred_section_lists_names() {
509 let stubs = vec![
510 make_stub("fs__read_file", "Read a file"),
511 make_stub("git__status", "Git status"),
512 ];
513 let set = DeferredMcpToolSet {
514 stubs,
515 registry: std::sync::Arc::new(
516 tokio::runtime::Runtime::new()
517 .unwrap()
518 .block_on(McpRegistry::connect_all(&[]))
519 .unwrap(),
520 ),
521 };
522 let section = build_deferred_tools_section(&set);
523 assert!(section.contains("<available-deferred-tools>"));
524 assert!(section.contains("fs__read_file - Read a file"));
525 assert!(section.contains("git__status - Git status"));
526 assert!(section.contains("</available-deferred-tools>"));
527 }
528
529 #[test]
530 fn build_deferred_section_includes_tool_search_instruction() {
531 let stubs = vec![make_stub("fs__read_file", "Read a file")];
532 let set = DeferredMcpToolSet {
533 stubs,
534 registry: std::sync::Arc::new(
535 tokio::runtime::Runtime::new()
536 .unwrap()
537 .block_on(McpRegistry::connect_all(&[]))
538 .unwrap(),
539 ),
540 };
541 let section = build_deferred_tools_section(&set);
542 assert!(
543 section.contains("tool_search"),
544 "deferred section must instruct the LLM to use tool_search"
545 );
546 assert!(
547 section.contains("## Deferred Tools"),
548 "deferred section must include a heading"
549 );
550 }
551
552 #[test]
553 fn build_deferred_section_multiple_servers() {
554 let stubs = vec![
555 make_stub("server_a__list", "List items"),
556 make_stub("server_a__create", "Create item"),
557 make_stub("server_b__query", "Query records"),
558 ];
559 let set = DeferredMcpToolSet {
560 stubs,
561 registry: std::sync::Arc::new(
562 tokio::runtime::Runtime::new()
563 .unwrap()
564 .block_on(McpRegistry::connect_all(&[]))
565 .unwrap(),
566 ),
567 };
568 let section = build_deferred_tools_section(&set);
569 assert!(section.contains("server_a__list"));
570 assert!(section.contains("server_a__create"));
571 assert!(section.contains("server_b__query"));
572 assert!(
573 section.contains("tool_search"),
574 "section must mention tool_search for multi-server setups"
575 );
576 }
577
578 #[test]
579 fn keyword_search_ranks_by_hits() {
580 let stubs = vec![
581 make_stub("fs__read_file", "Read a file from disk"),
582 make_stub("fs__write_file", "Write a file to disk"),
583 make_stub("git__log", "Show git log"),
584 ];
585 let set = DeferredMcpToolSet {
586 stubs,
587 registry: std::sync::Arc::new(
588 tokio::runtime::Runtime::new()
589 .unwrap()
590 .block_on(McpRegistry::connect_all(&[]))
591 .unwrap(),
592 ),
593 };
594
595 let results = set.search("file read", 5);
597 assert!(!results.is_empty());
598 assert_eq!(results[0].prefixed_name, "fs__read_file");
599 }
600
601 #[test]
602 fn get_by_name_returns_correct_stub() {
603 let stubs = vec![
604 make_stub("a__one", "Tool one"),
605 make_stub("b__two", "Tool two"),
606 ];
607 let set = DeferredMcpToolSet {
608 stubs,
609 registry: std::sync::Arc::new(
610 tokio::runtime::Runtime::new()
611 .unwrap()
612 .block_on(McpRegistry::connect_all(&[]))
613 .unwrap(),
614 ),
615 };
616 assert!(set.get_by_name("a__one").is_some());
617 assert!(set.get_by_name("nonexistent").is_none());
618 }
619
620 #[test]
621 fn get_by_name_resolves_unique_suffix() {
622 let stubs = vec![
623 make_stub("construct-operator__spawn_team", "Spawn a team"),
624 make_stub("construct-operator__create_agent", "Create an agent"),
625 make_stub("kumiho-memory__kumiho_memory_engage", "Engage memory"),
626 ];
627 let set = DeferredMcpToolSet {
628 stubs,
629 registry: std::sync::Arc::new(
630 tokio::runtime::Runtime::new()
631 .unwrap()
632 .block_on(McpRegistry::connect_all(&[]))
633 .unwrap(),
634 ),
635 };
636 let stub = set.get_by_name("spawn_team").unwrap();
638 assert_eq!(stub.prefixed_name, "construct-operator__spawn_team");
639 let stub = set.get_by_name("create_agent").unwrap();
640 assert_eq!(stub.prefixed_name, "construct-operator__create_agent");
641 assert!(set.get_by_name("operator__spawn_team").is_none());
643 }
644
645 #[test]
646 fn get_by_name_rejects_ambiguous_suffix() {
647 let stubs = vec![
648 make_stub("server_a__read", "Read from A"),
649 make_stub("server_b__read", "Read from B"),
650 ];
651 let set = DeferredMcpToolSet {
652 stubs,
653 registry: std::sync::Arc::new(
654 tokio::runtime::Runtime::new()
655 .unwrap()
656 .block_on(McpRegistry::connect_all(&[]))
657 .unwrap(),
658 ),
659 };
660 assert!(set.get_by_name("read").is_none());
662 }
663
664 #[test]
665 fn search_across_multiple_servers() {
666 let stubs = vec![
667 make_stub("server_a__read_file", "Read a file from disk"),
668 make_stub("server_b__read_config", "Read configuration from database"),
669 ];
670 let set = DeferredMcpToolSet {
671 stubs,
672 registry: std::sync::Arc::new(
673 tokio::runtime::Runtime::new()
674 .unwrap()
675 .block_on(McpRegistry::connect_all(&[]))
676 .unwrap(),
677 ),
678 };
679
680 let results = set.search("read", 10);
682 assert_eq!(results.len(), 2);
683
684 let results = set.search("file", 10);
686 assert_eq!(results.len(), 1);
687 assert_eq!(results[0].prefixed_name, "server_a__read_file");
688
689 let results = set.search("config database", 10);
691 assert!(!results.is_empty());
692 assert_eq!(results[0].prefixed_name, "server_b__read_config");
693 }
694}