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