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 "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 "include": { "type": "string", "description": "File filter glob (e.g. *.ts, *.{rs,ts}, src/**/*.tsx)" },
31 "ext": { "type": "string", "description": "Deprecated alias for `include`: a bare extension like `rs` or `.rs` is treated as `*.rs`. Prefer `include`." },
32 "max_results": { "type": "integer", "description": "Max results (default: 20)" },
33 "ignore_gitignore": { "type": "boolean", "description": "Set true to scan ALL files including .gitignore'd paths (default: false). Requires role policy (e.g. admin)." }
34 },
35 "required": ["pattern"]
36 }),
37 )
38 }
39
40 fn handle(
41 &self,
42 args: &Map<String, Value>,
43 ctx: &ToolContext,
44 ) -> Result<ToolOutput, ErrorData> {
45 let pattern = get_str(args, "pattern")
46 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
47 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx);
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_original: usize = 0;
82 let mut total_observed: usize = 0;
83 let mut total_sent: usize = 0;
84
85 for root in &resolved.roots {
86 let pat = pattern.clone();
87 let r = root.clone();
88 let inc = include.clone();
89
90 let search_result = tokio::task::block_in_place(|| {
91 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
92 crate::tools::ctx_search::handle(
93 &pat,
94 &r,
95 inc.as_deref(),
96 per_root_max,
97 crp,
98 respect,
99 allow_secret_paths,
100 )
101 }))
102 .ok()
103 });
104
105 let Some(outcome) = search_result else {
106 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
107 continue;
108 };
109 let result = outcome.text;
110
111 if result.starts_with("ERROR:") || result.trim().is_empty() {
112 if !result.trim().is_empty() {
113 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
114 }
115 continue;
116 }
117
118 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
119 total_original += outcome.modeled_baseline;
120 total_observed += outcome.observed_tokens;
121 total_sent += crate::core::tokens::count_tokens(&result);
122 }
123
124 if combined.is_empty() {
125 combined = "No matches found across any root.".to_string();
126 }
127
128 let final_out =
129 crate::core::protocol::append_savings(&combined, total_original, total_sent);
130 let saved = total_original.saturating_sub(total_sent);
131 crate::core::savings_ledger::record_tool_event(
132 "ctx_search",
133 total_observed,
134 total_observed.saturating_sub(total_sent),
135 );
136
137 Ok(ToolOutput {
138 text: final_out,
139 original_tokens: total_original,
140 saved_tokens: saved,
141 mode: None,
142 path: None,
143 changed: false,
144 shell_outcome: None,
145 })
146 }
147}
148
149fn search_single(
150 pattern: &str,
151 path: &str,
152 include: Option<&str>,
153 max: usize,
154 crp: crate::tools::CrpMode,
155 respect_gitignore: bool,
156 allow_secret_paths: bool,
157) -> Result<ToolOutput, ErrorData> {
158 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
159 let pattern_clone = pattern.to_string();
160 let path_clone = path.to_string();
161
162 let search_result = tokio::task::block_in_place(|| {
163 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
164 crate::tools::ctx_search::handle(
165 &pattern_clone,
166 &path_clone,
167 include,
168 max,
169 crp,
170 respect_gitignore,
171 allow_secret_paths,
172 )
173 }));
174 match result {
175 Ok(r) => Ok(r),
176 Err(_) => Err("search task panicked"),
177 }
178 });
179
180 let outcome = match search_result {
181 Ok(r) => r,
182 Err(e) => {
183 return Err(ErrorData::internal_error(
184 format!("search task failed: {e}"),
185 None,
186 ));
187 }
188 };
189 let result = outcome.text;
190 let original = outcome.modeled_baseline;
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 = original.saturating_sub(sent);
198 let final_out = crate::core::protocol::append_savings(&result, original, sent);
199 crate::core::savings_ledger::record_tool_event(
200 "ctx_search",
201 outcome.observed_tokens,
202 outcome.observed_tokens.saturating_sub(sent),
203 );
204
205 Ok(ToolOutput {
206 text: final_out,
207 original_tokens: original,
208 saved_tokens: saved,
209 mode: None,
210 path: Some(path.to_string()),
211 changed: false,
212 shell_outcome: None,
213 })
214}
215
216fn ext_to_include(ext: &str) -> String {
224 if ext.contains(['*', '{', '?', '/']) {
225 return ext.to_string();
226 }
227 let bare = ext.strip_prefix('.').unwrap_or(ext);
228 format!("*.{bare}")
229}
230
231#[cfg(test)]
232mod tests {
233 use super::ext_to_include;
234
235 #[test]
236 fn ext_alias_bare_extension_becomes_glob() {
237 assert_eq!(ext_to_include("rs"), "*.rs");
238 assert_eq!(ext_to_include("ts"), "*.ts");
239 }
240
241 #[test]
242 fn ext_alias_strips_leading_dot() {
243 assert_eq!(ext_to_include(".rs"), "*.rs");
244 assert_eq!(ext_to_include(".tsx"), "*.tsx");
245 }
246
247 #[test]
248 fn ext_alias_passes_through_glob_like_values() {
249 assert_eq!(ext_to_include("*.rs"), "*.rs");
251 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
252 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
253 }
254}