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