Skip to main content

construct/tools/
mcp_deferred.rs

1//! Deferred MCP tool loading — stubs and activated-tool tracking.
2//!
3//! When `mcp.deferred_loading` is enabled, MCP tool schemas are NOT eagerly
4//! included in the LLM context window. Instead, only lightweight stubs (name +
5//! description) are exposed in the system prompt. The LLM must call the built-in
6//! `tool_search` tool to fetch full schemas, which moves them into the
7//! [`ActivatedToolSet`] for the current conversation.
8
9use 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// ── DeferredMcpToolStub ──────────────────────────────────────────────────
18
19/// A lightweight stub representing a known-but-not-yet-loaded MCP tool.
20/// Contains only the prefixed name, a human-readable description, and enough
21/// information to construct the full [`McpToolWrapper`] on activation.
22#[derive(Debug, Clone)]
23pub struct DeferredMcpToolStub {
24    /// Prefixed name: `<server_name>__<tool_name>`.
25    pub prefixed_name: String,
26    /// Human-readable description (extracted from the MCP tool definition).
27    pub description: String,
28    /// The full tool definition — stored so we can construct a wrapper later.
29    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    /// Materialize this stub into a live [`McpToolWrapper`].
46    pub fn activate(&self, registry: Arc<McpRegistry>) -> McpToolWrapper {
47        McpToolWrapper::new(self.prefixed_name.clone(), self.def.clone(), registry)
48    }
49}
50
51// ── DeferredMcpToolSet ───────────────────────────────────────────────────
52
53/// Collection of all deferred MCP tool stubs discovered at startup.
54/// Provides keyword search for `tool_search`.
55#[derive(Clone)]
56pub struct DeferredMcpToolSet {
57    /// All stubs — exposed for test construction.
58    pub stubs: Vec<DeferredMcpToolStub>,
59    /// Shared registry — exposed for test construction.
60    pub registry: Arc<McpRegistry>,
61}
62
63impl DeferredMcpToolSet {
64    /// Build the set from a connected [`McpRegistry`].
65    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    /// Build the set from a connected [`McpRegistry`], including only tools
77    /// whose prefixed name passes the given filter predicate.
78    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    /// All stub names (for rendering in the system prompt).
96    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    /// Number of deferred stubs.
104    pub fn len(&self) -> usize {
105        self.stubs.len()
106    }
107
108    /// Whether the set is empty.
109    pub fn is_empty(&self) -> bool {
110        self.stubs.is_empty()
111    }
112
113    /// Look up stubs by exact name, falling back to unique suffix match.
114    ///
115    /// Some providers (or prompt instructions) reference MCP tools without the
116    /// `<server>__` prefix.  When the suffix maps to exactly one stub, allow
117    /// the lookup to succeed — same pattern as `ActivatedToolSet::get_resolved`.
118    pub fn get_by_name(&self, name: &str) -> Option<&DeferredMcpToolStub> {
119        // Exact match first.
120        if let Some(stub) = self.stubs.iter().find(|s| s.prefixed_name == name) {
121            return Some(stub);
122        }
123        // If the name already contains `__`, it was a prefixed miss — don't suffix-match.
124        if name.contains("__") {
125            return None;
126        }
127        // Suffix match: find stubs where the part after `__` equals `name`.
128        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                // Ambiguous — more than one stub shares this suffix.
138                return None;
139            }
140            resolved = Some(stub);
141        }
142        resolved
143    }
144
145    /// Keyword search — returns stubs whose name or description contains any
146    /// of the query terms (case-insensitive). Results are ranked by number of
147    /// matching terms (descending).
148    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    /// Activate a stub by name, returning a boxed [`Tool`].
183    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    /// Return the full [`ToolSpec`] for a stub (for inclusion in `tool_search` results).
191    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
199// ── ActivatedToolSet ─────────────────────────────────────────────────────
200
201/// Per-conversation mutable state tracking which deferred tools have been
202/// activated (i.e. their full schemas have been fetched via `tool_search`).
203/// The agent loop consults this each iteration to decide which tool_specs
204/// to include in the LLM request.
205pub 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    /// Clone the Arc so the caller can drop the mutex guard before awaiting.
225    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
226        self.tools.get(name).cloned()
227    }
228
229    /// Resolve an activated tool by exact name first, then by unique MCP suffix.
230    ///
231    /// Some providers occasionally strip the `<server>__` prefix when calling a
232    /// deferred MCP tool after `tool_search` activation. When the suffix maps to
233    /// exactly one activated tool, allow that call to proceed.
234    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
274// ── Local-model eager subset ─────────────────────────────────────────────
275
276/// Minimal set of operator tool suffixes loaded eagerly for local models
277/// (e.g. Ollama). Everything else is deferred behind `tool_search`.
278///
279/// Local models (Gemma4, Llama, etc.) struggle with large native tool sets
280/// (100+ definitions cause hallucinated tool names). This list keeps the
281/// essentials eager while deferring the rest to `tool_search` discovery.
282pub const LOCAL_MODEL_EAGER_SUFFIXES: &[&str] = &[
283    // Agent lifecycle — core operator loop
284    "create_agent",
285    "wait_for_agent",
286    "send_agent_prompt",
287    "get_agent_activity",
288    "list_agents",
289    "cancel_agent",
290    // Outcome
291    "resolve_outcome",
292    // Workflow context
293    "get_workflow_context",
294    // Planning
295    "save_plan",
296    "recall_plans",
297    // Compaction
298    "compact_conversation",
299];
300
301/// Returns `true` if this MCP tool name should be eagerly loaded for a
302/// local model, based on whether its unprefixed suffix matches
303/// [`LOCAL_MODEL_EAGER_SUFFIXES`].
304pub 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
310/// Kumiho memory reflexes — kept eager for every provider hosting the
311/// Operator seat so the agent can `engage`/`reflect` without a
312/// `tool_search` indirection on every turn.
313pub const OPERATOR_MEMORY_REFLEX_TOOLS: &[&str] = &[
314    "kumiho-memory__kumiho_memory_engage",
315    "kumiho-memory__kumiho_memory_reflect",
316];
317
318/// Returns `true` if this MCP tool name should be eagerly loaded for any
319/// provider hosting the Operator seat (cloud or local).  Combines the
320/// curated operator essentials with the Kumiho memory reflexes.  Cloud
321/// providers previously loaded *all* operator tools (100+), blowing out
322/// per-turn input tokens; the rest are discoverable via `tool_search`.
323pub 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
330// ── System prompt helper ─────────────────────────────────────────────────
331
332/// Build the `<available-deferred-tools>` section for the system prompt.
333/// Lists only tool names so the LLM knows what is available without
334/// consuming context window on full schemas. Includes an instruction
335/// block that tells the LLM to call `tool_search` to activate them.
336pub 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        // "file read" should rank fs__read_file highest (2 hits vs 1)
596        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        // Suffix match should resolve unambiguously
637        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        // Prefixed name already contains __ so should NOT suffix-match
642        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        // "read" matches both servers — should return None
661        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        // "read" should match stubs from both servers
681        let results = set.search("read", 10);
682        assert_eq!(results.len(), 2);
683
684        // "file" should match only server_a
685        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        // "config database" should rank server_b highest (2 hits)
690        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}