Skip to main content

construct/tools/
tool_search.rs

1//! Built-in `tool_search` tool for on-demand MCP tool schema loading.
2//!
3//! When `mcp.deferred_loading` is enabled, this tool lets the LLM discover and
4//! activate deferred MCP tools. Supports two query modes:
5//! - `select:name1,name2` — fetch exact tools by prefixed name.
6//! - Free-text keyword search — returns the best-matching stubs.
7
8use std::fmt::Write;
9use std::sync::{Arc, Mutex};
10
11use async_trait::async_trait;
12
13use crate::tools::mcp_deferred::{ActivatedToolSet, DeferredMcpToolSet};
14use crate::tools::traits::{Tool, ToolResult};
15
16/// Default maximum number of search results.
17const DEFAULT_MAX_RESULTS: usize = 5;
18
19/// Built-in tool that fetches full schemas for deferred MCP tools.
20pub struct ToolSearchTool {
21    deferred: DeferredMcpToolSet,
22    activated: Arc<Mutex<ActivatedToolSet>>,
23}
24
25impl ToolSearchTool {
26    pub fn new(deferred: DeferredMcpToolSet, activated: Arc<Mutex<ActivatedToolSet>>) -> Self {
27        Self {
28            deferred,
29            activated,
30        }
31    }
32}
33
34#[async_trait]
35impl Tool for ToolSearchTool {
36    fn name(&self) -> &str {
37        "tool_search"
38    }
39
40    fn description(&self) -> &str {
41        "Fetch full schema definitions for deferred MCP tools so they can be called. \
42         Use \"select:name1,name2\" for exact match or keywords to search."
43    }
44
45    fn parameters_schema(&self) -> serde_json::Value {
46        serde_json::json!({
47            "type": "object",
48            "properties": {
49                "query": {
50                    "description": "Query to find deferred tools. Use \"select:<tool_name>\" for direct selection, or keywords to search.",
51                    "type": "string"
52                },
53                "max_results": {
54                    "description": "Maximum number of results to return (default: 5)",
55                    "type": "number",
56                    "default": DEFAULT_MAX_RESULTS
57                }
58            },
59            "required": ["query"]
60        })
61    }
62
63    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
64        let query = args
65            .get("query")
66            .and_then(|v| v.as_str())
67            .unwrap_or_default()
68            .trim();
69
70        let max_results = args
71            .get("max_results")
72            .and_then(|v| v.as_u64())
73            .map(|v| usize::try_from(v).unwrap_or(DEFAULT_MAX_RESULTS))
74            .unwrap_or(DEFAULT_MAX_RESULTS);
75
76        if query.is_empty() {
77            return Ok(ToolResult {
78                success: false,
79                output: String::new(),
80                error: Some("query parameter is required".into()),
81            });
82        }
83
84        // Parse query mode
85        if let Some(names_str) = query.strip_prefix("select:") {
86            // Exact selection mode
87            let names: Vec<&str> = names_str.split(',').map(str::trim).collect();
88            return self.select_tools(&names);
89        }
90
91        // Keyword search mode
92        let results = self.deferred.search(query, max_results);
93        if results.is_empty() {
94            return Ok(ToolResult {
95                success: true,
96                output: "No matching deferred tools found.".into(),
97                error: None,
98            });
99        }
100
101        // Activate and return full specs
102        let mut output = String::from("<functions>\n");
103        let mut activated_count = 0;
104        let mut guard = self.activated.lock().unwrap();
105
106        for stub in &results {
107            if let Some(spec) = self.deferred.tool_spec(&stub.prefixed_name) {
108                if !guard.is_activated(&stub.prefixed_name) {
109                    if let Some(tool) = self.deferred.activate(&stub.prefixed_name) {
110                        guard.activate(stub.prefixed_name.clone(), Arc::from(tool));
111                        activated_count += 1;
112                    }
113                }
114                let _ = writeln!(
115                    output,
116                    "<function>{{\"name\": \"{}\", \"description\": \"{}\", \"parameters\": {}}}</function>",
117                    spec.name,
118                    spec.description.replace('"', "\\\""),
119                    spec.parameters
120                );
121            }
122        }
123
124        output.push_str("</functions>\n");
125        drop(guard);
126
127        tracing::debug!(
128            "tool_search: query={query:?}, matched={}, activated={activated_count}",
129            results.len()
130        );
131
132        Ok(ToolResult {
133            success: true,
134            output,
135            error: None,
136        })
137    }
138}
139
140impl ToolSearchTool {
141    fn select_tools(&self, names: &[&str]) -> anyhow::Result<ToolResult> {
142        let mut output = String::from("<functions>\n");
143        let mut not_found = Vec::new();
144        let mut activated_count = 0;
145        let mut guard = self.activated.lock().unwrap();
146
147        for name in names {
148            if name.is_empty() {
149                continue;
150            }
151            // get_by_name handles both exact and suffix-resolved lookups.
152            match self.deferred.get_by_name(name) {
153                Some(stub) => {
154                    let full_name = &stub.prefixed_name;
155                    if let Some(spec) = self.deferred.tool_spec(full_name) {
156                        if !guard.is_activated(full_name) {
157                            if let Some(tool) = self.deferred.activate(full_name) {
158                                guard.activate(full_name.clone(), Arc::from(tool));
159                                activated_count += 1;
160                            }
161                        }
162                        let _ = writeln!(
163                            output,
164                            "<function>{{\"name\": \"{}\", \"description\": \"{}\", \"parameters\": {}}}</function>",
165                            spec.name,
166                            spec.description.replace('"', "\\\""),
167                            spec.parameters
168                        );
169                    }
170                }
171                None => {
172                    not_found.push(*name);
173                }
174            }
175        }
176
177        output.push_str("</functions>\n");
178        drop(guard);
179
180        if !not_found.is_empty() {
181            let _ = write!(output, "\nNot found: {}", not_found.join(", "));
182        }
183
184        tracing::debug!(
185            "tool_search select: requested={}, activated={activated_count}, not_found={}",
186            names.len(),
187            not_found.len()
188        );
189
190        Ok(ToolResult {
191            success: true,
192            output,
193            error: None,
194        })
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::tools::mcp_client::McpRegistry;
202    use crate::tools::mcp_deferred::DeferredMcpToolStub;
203    use crate::tools::mcp_protocol::McpToolDef;
204
205    async fn make_deferred_set(stubs: Vec<DeferredMcpToolStub>) -> DeferredMcpToolSet {
206        let registry = Arc::new(McpRegistry::connect_all(&[]).await.unwrap());
207        DeferredMcpToolSet { stubs, registry }
208    }
209
210    fn make_stub(name: &str, desc: &str) -> DeferredMcpToolStub {
211        let def = McpToolDef {
212            name: name.to_string(),
213            description: Some(desc.to_string()),
214            input_schema: serde_json::json!({"type": "object", "properties": {}}),
215        };
216        DeferredMcpToolStub::new(name.to_string(), def)
217    }
218
219    #[tokio::test]
220    async fn tool_metadata() {
221        let tool = ToolSearchTool::new(
222            make_deferred_set(vec![]).await,
223            Arc::new(Mutex::new(ActivatedToolSet::new())),
224        );
225        assert_eq!(tool.name(), "tool_search");
226        assert!(!tool.description().is_empty());
227        assert!(tool.parameters_schema()["properties"]["query"].is_object());
228    }
229
230    #[tokio::test]
231    async fn empty_query_returns_error() {
232        let tool = ToolSearchTool::new(
233            make_deferred_set(vec![]).await,
234            Arc::new(Mutex::new(ActivatedToolSet::new())),
235        );
236        let result = tool
237            .execute(serde_json::json!({"query": ""}))
238            .await
239            .unwrap();
240        assert!(!result.success);
241    }
242
243    #[tokio::test]
244    async fn select_nonexistent_tool_reports_not_found() {
245        let tool = ToolSearchTool::new(
246            make_deferred_set(vec![]).await,
247            Arc::new(Mutex::new(ActivatedToolSet::new())),
248        );
249        let result = tool
250            .execute(serde_json::json!({"query": "select:nonexistent"}))
251            .await
252            .unwrap();
253        assert!(result.success);
254        assert!(result.output.contains("Not found"));
255    }
256
257    #[tokio::test]
258    async fn keyword_search_no_matches() {
259        let tool = ToolSearchTool::new(
260            make_deferred_set(vec![make_stub("fs__read", "Read file")]).await,
261            Arc::new(Mutex::new(ActivatedToolSet::new())),
262        );
263        let result = tool
264            .execute(serde_json::json!({"query": "zzzzz_nonexistent"}))
265            .await
266            .unwrap();
267        assert!(result.success);
268        assert!(result.output.contains("No matching"));
269    }
270
271    #[tokio::test]
272    async fn keyword_search_finds_match() {
273        let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
274        let tool = ToolSearchTool::new(
275            make_deferred_set(vec![make_stub("fs__read", "Read a file from disk")]).await,
276            Arc::clone(&activated),
277        );
278        let result = tool
279            .execute(serde_json::json!({"query": "read file"}))
280            .await
281            .unwrap();
282        assert!(result.success);
283        assert!(result.output.contains("<function>"));
284        assert!(result.output.contains("fs__read"));
285        // Tool should now be activated
286        assert!(activated.lock().unwrap().is_activated("fs__read"));
287    }
288
289    /// Verify tool_search works with stubs from multiple MCP servers,
290    /// simulating a daemon-mode setup where several servers are deferred.
291    #[tokio::test]
292    async fn multiple_servers_stubs_all_searchable() {
293        let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
294        let stubs = vec![
295            make_stub("server_a__list_files", "List files on server A"),
296            make_stub("server_a__read_file", "Read file on server A"),
297            make_stub("server_b__query_db", "Query database on server B"),
298            make_stub("server_b__insert_row", "Insert row on server B"),
299        ];
300        let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
301
302        // Search should find tools across both servers
303        let result = tool
304            .execute(serde_json::json!({"query": "file"}))
305            .await
306            .unwrap();
307        assert!(result.success);
308        assert!(result.output.contains("server_a__list_files"));
309        assert!(result.output.contains("server_a__read_file"));
310
311        // Server B tools should also be searchable
312        let result = tool
313            .execute(serde_json::json!({"query": "database query"}))
314            .await
315            .unwrap();
316        assert!(result.success);
317        assert!(result.output.contains("server_b__query_db"));
318    }
319
320    /// Verify select mode activates tools and they stay activated across calls,
321    /// matching the daemon-mode pattern where a single ActivatedToolSet persists.
322    #[tokio::test]
323    async fn select_activates_and_persists_across_calls() {
324        let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
325        let stubs = vec![
326            make_stub("srv__tool_a", "Tool A"),
327            make_stub("srv__tool_b", "Tool B"),
328        ];
329        let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
330
331        // Activate tool_a
332        let result = tool
333            .execute(serde_json::json!({"query": "select:srv__tool_a"}))
334            .await
335            .unwrap();
336        assert!(result.success);
337        assert!(activated.lock().unwrap().is_activated("srv__tool_a"));
338        assert!(!activated.lock().unwrap().is_activated("srv__tool_b"));
339
340        // Activate tool_b in a separate call
341        let result = tool
342            .execute(serde_json::json!({"query": "select:srv__tool_b"}))
343            .await
344            .unwrap();
345        assert!(result.success);
346
347        // Both should remain activated
348        let guard = activated.lock().unwrap();
349        assert!(guard.is_activated("srv__tool_a"));
350        assert!(guard.is_activated("srv__tool_b"));
351        assert_eq!(guard.tool_specs().len(), 2);
352    }
353
354    /// Verify re-activating an already-activated tool does not duplicate it.
355    #[tokio::test]
356    async fn reactivation_is_idempotent() {
357        let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
358        let tool = ToolSearchTool::new(
359            make_deferred_set(vec![make_stub("srv__tool", "A tool")]).await,
360            Arc::clone(&activated),
361        );
362
363        tool.execute(serde_json::json!({"query": "select:srv__tool"}))
364            .await
365            .unwrap();
366        tool.execute(serde_json::json!({"query": "select:srv__tool"}))
367            .await
368            .unwrap();
369
370        assert_eq!(activated.lock().unwrap().tool_specs().len(), 1);
371    }
372}