Skip to main content

lean_ctx/tools/registered/
ctx_search.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_bool, get_int, get_str, McpTool, ToolContext, ToolOutput};
6use crate::tool_defs::tool_def;
7
8pub struct CtxSearchTool;
9
10impl McpTool for CtxSearchTool {
11    fn name(&self) -> &'static str {
12        "ctx_search"
13    }
14
15    fn tool_def(&self) -> Tool {
16        tool_def(
17            "ctx_search",
18            "Search code by regex. Prefer over native Grep/rg/find (compact output).\n\
19             Respects .gitignore; supports multi-root via `paths` array. Secret-like files skipped unless role allows.",
20            json!({
21                "type": "object",
22                "properties": {
23                    "pattern": { "type": "string", "description": "Regex pattern" },
24                    "path": { "type": "string", "description": "Directory to search" },
25                    "paths": {
26                        "type": "array",
27                        "items": { "type": "string" },
28                        "description": "Multiple directories to search (alternative to path)"
29                    },
30                    "ext": { "type": "string", "description": "File extension filter" },
31                    "max_results": { "type": "integer", "description": "Max results (default: 20)" },
32                    "ignore_gitignore": { "type": "boolean", "description": "Set true to scan ALL files including .gitignore'd paths (default: false). Requires role policy (e.g. admin)." }
33                },
34                "required": ["pattern"]
35            }),
36        )
37    }
38
39    fn handle(
40        &self,
41        args: &Map<String, Value>,
42        ctx: &ToolContext,
43    ) -> Result<ToolOutput, ErrorData> {
44        let pattern = get_str(args, "pattern")
45            .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
46        let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx);
47        let ext = get_str(args, "ext");
48        let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
49        let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
50
51        if no_gitignore {
52            if let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
53            {
54                return Ok(ToolOutput::simple(e));
55            }
56        }
57
58        let crp = ctx.crp_mode;
59        let respect = !no_gitignore;
60        let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
61
62        if !resolved.is_multi {
63            return search_single(
64                &pattern,
65                &resolved.roots[0],
66                ext.as_deref(),
67                max,
68                crp,
69                respect,
70                allow_secret_paths,
71            );
72        }
73
74        let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
75        let per_root_max = (max / resolved.roots.len()).max(5);
76        let mut combined = String::new();
77        let mut total_original: usize = 0;
78        let mut total_sent: usize = 0;
79
80        for root in &resolved.roots {
81            let pat = pattern.clone();
82            let r = root.clone();
83            let e = ext.clone();
84
85            let search_result = tokio::task::block_in_place(|| {
86                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
87                    crate::tools::ctx_search::handle(
88                        &pat,
89                        &r,
90                        e.as_deref(),
91                        per_root_max,
92                        crp,
93                        respect,
94                        allow_secret_paths,
95                    )
96                }))
97                .ok()
98            });
99
100            let Some((result, original)) = search_result else {
101                combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
102                continue;
103            };
104
105            if result.starts_with("ERROR:") || result.trim().is_empty() {
106                if !result.trim().is_empty() {
107                    combined.push_str(&format!("── {root} ──\n{result}\n\n"));
108                }
109                continue;
110            }
111
112            combined.push_str(&format!("── {root} ──\n{result}\n\n"));
113            total_original += original;
114            total_sent += crate::core::tokens::count_tokens(&result);
115        }
116
117        if combined.is_empty() {
118            combined = "No matches found across any root.".to_string();
119        }
120
121        let final_out =
122            crate::core::protocol::append_savings(&combined, total_original, total_sent);
123        let saved = total_original.saturating_sub(total_sent);
124
125        Ok(ToolOutput {
126            text: final_out,
127            original_tokens: total_original,
128            saved_tokens: saved,
129            mode: None,
130            path: None,
131            changed: false,
132        })
133    }
134}
135
136fn search_single(
137    pattern: &str,
138    path: &str,
139    ext: Option<&str>,
140    max: usize,
141    crp: crate::tools::CrpMode,
142    respect_gitignore: bool,
143    allow_secret_paths: bool,
144) -> Result<ToolOutput, ErrorData> {
145    let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
146    let pattern_clone = pattern.to_string();
147    let path_clone = path.to_string();
148
149    let search_result = tokio::task::block_in_place(|| {
150        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
151            crate::tools::ctx_search::handle(
152                &pattern_clone,
153                &path_clone,
154                ext,
155                max,
156                crp,
157                respect_gitignore,
158                allow_secret_paths,
159            )
160        }));
161        match result {
162            Ok(r) => Ok(r),
163            Err(_) => Err("search task panicked"),
164        }
165    });
166
167    let (result, original) = match search_result {
168        Ok(r) => r,
169        Err(e) => {
170            return Err(ErrorData::internal_error(
171                format!("search task failed: {e}"),
172                None,
173            ));
174        }
175    };
176
177    if result.starts_with("ERROR:") {
178        return Err(ErrorData::invalid_params(result, None));
179    }
180
181    let sent = crate::core::tokens::count_tokens(&result);
182    let saved = original.saturating_sub(sent);
183    let final_out = crate::core::protocol::append_savings(&result, original, sent);
184
185    Ok(ToolOutput {
186        text: final_out,
187        original_tokens: original,
188        saved_tokens: saved,
189        mode: None,
190        path: Some(path.to_string()),
191        changed: false,
192    })
193}