lean_ctx/tools/registered/
ctx_search.rs1use 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 "Regex code search. Prefer over native Grep/rg/find (compact, .gitignore-aware).",
19 json!({
20 "type": "object",
21 "properties": {
22 "pattern": { "type": "string", "description": "Regex pattern" },
23 "path": { "type": "string", "description": "Directory to search" },
24 "paths": {
25 "type": "array",
26 "items": { "type": "string" },
27 "description": "Multiple roots (alternative to path)"
28 },
29 "include": { "type": "string", "description": "Glob filter, e.g. *.ts, src/**/*.rs" },
30 "ext": { "type": "string", "description": "Deprecated; use include" },
31 "max_results": { "type": "integer", "description": "Default 20" },
32 "ignore_gitignore": { "type": "boolean", "description": "Also scan gitignored files (needs role)" }
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 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
48 let include =
51 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
52 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
53 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
54
55 if no_gitignore {
56 if let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
57 {
58 return Ok(ToolOutput::simple(e));
59 }
60 }
61
62 let crp = ctx.crp_mode;
63 let respect = !no_gitignore;
64 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
65
66 if !resolved.is_multi {
67 return search_single(
68 &pattern,
69 &resolved.roots[0],
70 include.as_deref(),
71 max,
72 crp,
73 respect,
74 allow_secret_paths,
75 );
76 }
77
78 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
79 let per_root_max = (max / resolved.roots.len()).max(5);
80 let mut combined = String::new();
81 let mut total_observed: usize = 0;
82 let mut total_sent: usize = 0;
83
84 for root in &resolved.roots {
85 let pat = pattern.clone();
86 let r = root.clone();
87 let inc = include.clone();
88
89 let search_result = tokio::task::block_in_place(|| {
90 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
91 crate::tools::ctx_search::handle(
92 &pat,
93 &r,
94 inc.as_deref(),
95 per_root_max,
96 crp,
97 respect,
98 allow_secret_paths,
99 )
100 }))
101 .ok()
102 });
103
104 let Some(outcome) = search_result else {
105 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
106 continue;
107 };
108 let result = outcome.text;
109
110 if result.starts_with("ERROR:") || result.trim().is_empty() {
111 if !result.trim().is_empty() {
112 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
113 }
114 continue;
115 }
116
117 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
118 total_observed += outcome.observed_tokens;
119 total_sent += crate::core::tokens::count_tokens(&result);
120 }
121
122 if combined.is_empty() {
123 combined = "No matches found across any root.".to_string();
124 }
125
126 let final_out =
131 crate::core::protocol::append_savings(&combined, total_observed, total_sent);
132 let saved = total_observed.saturating_sub(total_sent);
133 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, saved);
134
135 Ok(ToolOutput {
136 text: final_out,
137 original_tokens: total_observed,
138 saved_tokens: saved,
139 mode: None,
140 path: None,
141 changed: false,
142 shell_outcome: None,
143 })
144 }
145}
146
147fn search_single(
148 pattern: &str,
149 path: &str,
150 include: Option<&str>,
151 max: usize,
152 crp: crate::tools::CrpMode,
153 respect_gitignore: bool,
154 allow_secret_paths: bool,
155) -> Result<ToolOutput, ErrorData> {
156 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
157 let pattern_clone = pattern.to_string();
158 let path_clone = path.to_string();
159
160 let search_result = tokio::task::block_in_place(|| {
161 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
162 crate::tools::ctx_search::handle(
163 &pattern_clone,
164 &path_clone,
165 include,
166 max,
167 crp,
168 respect_gitignore,
169 allow_secret_paths,
170 )
171 }));
172 match result {
173 Ok(r) => Ok(r),
174 Err(_) => Err("search task panicked"),
175 }
176 });
177
178 let outcome = match search_result {
179 Ok(r) => r,
180 Err(e) => {
181 return Err(ErrorData::internal_error(
182 format!("search task failed: {e}"),
183 None,
184 ));
185 }
186 };
187 let result = outcome.text;
188 let observed = outcome.observed_tokens;
191
192 if result.starts_with("ERROR:") {
193 return Err(ErrorData::invalid_params(result, None));
194 }
195
196 let sent = crate::core::tokens::count_tokens(&result);
197 let saved = observed.saturating_sub(sent);
198 let final_out = crate::core::protocol::append_savings(&result, observed, sent);
199 crate::core::savings_ledger::record_tool_event("ctx_search", observed, saved);
200
201 Ok(ToolOutput {
202 text: final_out,
203 original_tokens: observed,
204 saved_tokens: saved,
205 mode: None,
206 path: Some(path.to_string()),
207 changed: false,
208 shell_outcome: None,
209 })
210}
211
212fn ext_to_include(ext: &str) -> String {
220 if ext.contains(['*', '{', '?', '/']) {
221 return ext.to_string();
222 }
223 let bare = ext.strip_prefix('.').unwrap_or(ext);
224 format!("*.{bare}")
225}
226
227#[cfg(test)]
228mod tests {
229 use super::ext_to_include;
230
231 #[test]
232 fn ext_alias_bare_extension_becomes_glob() {
233 assert_eq!(ext_to_include("rs"), "*.rs");
234 assert_eq!(ext_to_include("ts"), "*.ts");
235 }
236
237 #[test]
238 fn ext_alias_strips_leading_dot() {
239 assert_eq!(ext_to_include(".rs"), "*.rs");
240 assert_eq!(ext_to_include(".tsx"), "*.tsx");
241 }
242
243 #[test]
244 fn ext_alias_passes_through_glob_like_values() {
245 assert_eq!(ext_to_include("*.rs"), "*.rs");
247 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
248 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
249 }
250}