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 "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}