1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, get_str_array, get_usize,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxSearchTool;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum SearchAction {
17 Regex,
18 Semantic,
19 Symbol,
20 Reindex,
21 FindRelated,
22}
23
24impl SearchAction {
25 fn resolve(args: &Map<String, Value>) -> Self {
29 if let Some(a) = get_str(args, "action") {
30 match a.trim().to_ascii_lowercase().as_str() {
31 "regex" | "grep" | "pattern" => return Self::Regex,
32 "semantic" | "search" => return Self::Semantic,
33 "symbol" => return Self::Symbol,
34 "reindex" => return Self::Reindex,
35 "find_related" | "related" => return Self::FindRelated,
36 _ => {}
37 }
38 }
39 if args.contains_key("pattern") {
40 Self::Regex
41 } else if args.contains_key("name") {
42 Self::Symbol
43 } else if args.contains_key("file_path") && args.contains_key("line") {
44 Self::FindRelated
45 } else if args.contains_key("query") {
46 Self::Semantic
47 } else {
48 Self::Regex
49 }
50 }
51}
52
53impl McpTool for CtxSearchTool {
54 fn name(&self) -> &'static str {
55 "ctx_search"
56 }
57
58 fn tool_def(&self) -> Tool {
59 tool_def(
60 "ctx_search",
61 "Search code; `action` picks the engine. regex (default): exact pattern, `pattern`\n\
62 required, include='*.rs', paths=[..] multi-root. semantic: by meaning (BM25+embeddings),\n\
63 `query`, mode=bm25|dense|hybrid. symbol: one symbol's body by `name` (AST-precise),\n\
64 file/kind narrow. reindex / find_related(file_path,line). For end-to-end understanding,\n\
65 use ctx_compose FIRST.",
66 json!({
67 "type": "object",
68 "properties": {
69 "action": {
70 "type": "string",
71 "enum": ["regex", "semantic", "symbol", "reindex", "find_related"]
72 },
73 "pattern": { "type": "string", "description": "Regex (action=regex)" },
74 "query": { "type": "string", "description": "Meaning query (action=semantic)" },
75 "name": { "type": "string", "description": "Symbol name (action=symbol)" },
76 "path": { "type": "string", "description": "Scope dir / project root" },
77 "paths": {
78 "type": "array",
79 "items": { "type": "string" },
80 "description": "Multi-root regex"
81 },
82 "include": { "type": "string", "description": "Glob: *.ts, src/**/*.rs" },
83 "max_results": { "type": "integer" },
84 "top_k": { "type": "integer" },
85 "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
86 "file": { "type": "string", "description": "Narrow symbol to file" },
87 "kind": { "type": "string", "description": "fn|struct|class|trait|enum" },
88 "file_path": { "type": "string" },
89 "line": { "type": "integer" }
90 }
91 }),
92 )
93 }
94
95 fn handle(
96 &self,
97 args: &Map<String, Value>,
98 ctx: &ToolContext,
99 ) -> Result<ToolOutput, ErrorData> {
100 match SearchAction::resolve(args) {
101 SearchAction::Regex => handle_regex(args, ctx),
102 SearchAction::Semantic => handle_semantic(args, ctx),
103 SearchAction::Symbol => handle_symbol(args, ctx),
104 SearchAction::Reindex => handle_reindex(args, ctx),
105 SearchAction::FindRelated => handle_find_related(args, ctx),
106 }
107 }
108}
109
110fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
112 let pattern = get_str(args, "pattern")
113 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
114 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
115 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
116 let include =
119 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
120 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
121 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
122
123 if no_gitignore
124 && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
125 {
126 return Ok(ToolOutput::simple(e));
127 }
128
129 let crp = ctx.crp_mode;
130 let respect = !no_gitignore;
131 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
132
133 if !resolved.is_multi {
134 return search_single(
135 &pattern,
136 &resolved.roots[0],
137 include.as_deref(),
138 max,
139 crp,
140 respect,
141 allow_secret_paths,
142 );
143 }
144
145 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
146 let per_root_max = (max / resolved.roots.len()).max(5);
147 let mut combined = String::new();
148 let mut total_observed: usize = 0;
149 let mut total_sent: usize = 0;
150
151 for root in &resolved.roots {
152 let search_result = tokio::task::block_in_place(|| {
153 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
154 crate::tools::ctx_search::handle(
155 &pattern,
156 root,
157 include.as_deref(),
158 per_root_max,
159 crp,
160 respect,
161 allow_secret_paths,
162 )
163 }))
164 .ok()
165 });
166
167 let Some(outcome) = search_result else {
168 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
169 continue;
170 };
171 let result = outcome.text;
172
173 if result.trim().is_empty() {
174 continue;
175 }
176
177 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
178
179 if result.starts_with("ERROR:") {
180 continue;
181 }
182
183 total_observed += outcome.observed_tokens;
184 total_sent += crate::core::tokens::count_tokens(&result);
185 }
186
187 if combined.is_empty() {
188 combined = "No matches found across any root.".to_string();
189 }
190
191 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
196 let saved = total_observed.saturating_sub(total_sent);
197 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
201
202 Ok(ToolOutput {
203 text: final_out,
204 original_tokens: total_observed,
205 saved_tokens: saved,
206 mode: None,
207 path: None,
208 changed: false,
209 shell_outcome: None,
210 })
211}
212
213fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
216 if let Some(p) = ctx.resolved_path("path") {
217 Ok(p.to_string())
218 } else if let Some(err) = ctx.path_error("path") {
219 Err(ErrorData::invalid_params(format!("path: {err}"), None))
220 } else {
221 Ok(ctx.project_root.clone())
222 }
223}
224
225fn prime_bm25_cache(ctx: &ToolContext) {
228 if let Some(ref cache) = ctx.bm25_cache {
229 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
230 }
231}
232
233fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
235 let query = get_str(args, "query")
236 .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
237 let path = resolve_path_or_root(ctx)?;
238 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
239 let mode = get_str(args, "mode");
240 let languages = get_str_array(args, "languages");
241 let path_glob = get_str(args, "path_glob");
242 let workspace = get_bool(args, "workspace").unwrap_or(false);
243 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
244 prime_bm25_cache(ctx);
245
246 let result = tokio::task::block_in_place(|| {
247 crate::tools::ctx_semantic_search::handle(
248 &query,
249 &path,
250 top_k,
251 ctx.crp_mode,
252 languages.as_deref(),
253 path_glob.as_deref(),
254 mode.as_deref(),
255 Some(workspace),
256 Some(artifacts),
257 )
258 });
259 Ok(semantic_output(result))
260}
261
262fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
264 let name = get_str(args, "name")
265 .ok_or_else(|| ErrorData::invalid_params("name is required for action=symbol", None))?;
266 let file = get_str(args, "file");
267 let kind = get_str(args, "kind");
268
269 let (result, original) = crate::tools::ctx_symbol::handle(
270 &name,
271 file.as_deref(),
272 kind.as_deref(),
273 &ctx.project_root,
274 );
275 let sent = crate::core::tokens::count_tokens(&result);
276 Ok(ToolOutput {
277 text: result,
278 original_tokens: original,
279 saved_tokens: original.saturating_sub(sent),
280 mode: kind,
281 path: file,
282 changed: false,
283 shell_outcome: None,
284 })
285}
286
287fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
289 let path = resolve_path_or_root(ctx)?;
290 let workspace = get_bool(args, "workspace").unwrap_or(false);
291 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
292 prime_bm25_cache(ctx);
293
294 let result = tokio::task::block_in_place(|| {
295 if artifacts {
296 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
297 } else {
298 crate::tools::ctx_semantic_search::handle_reindex(&path)
299 }
300 });
301 Ok(semantic_output(result))
302}
303
304fn handle_find_related(
306 args: &Map<String, Value>,
307 ctx: &ToolContext,
308) -> Result<ToolOutput, ErrorData> {
309 let path = resolve_path_or_root(ctx)?;
310 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
311 let fp = get_str(args, "file_path").unwrap_or_default();
312 let line = get_int(args, "line").unwrap_or(1) as usize;
313 if fp.is_empty() {
314 return Err(ErrorData::invalid_params(
315 "find_related requires file_path and line",
316 None,
317 ));
318 }
319 prime_bm25_cache(ctx);
320
321 let result = tokio::task::block_in_place(|| {
322 crate::tools::ctx_semantic_search::handle_find_related(
323 &fp,
324 line,
325 &path,
326 top_k,
327 ctx.crp_mode,
328 )
329 });
330 Ok(semantic_output(result))
331}
332
333fn semantic_output(text: String) -> ToolOutput {
336 ToolOutput {
337 text,
338 original_tokens: 0,
339 saved_tokens: 0,
340 mode: Some("semantic".to_string()),
341 path: None,
342 changed: false,
343 shell_outcome: None,
344 }
345}
346
347fn search_single(
348 pattern: &str,
349 path: &str,
350 include: Option<&str>,
351 max: usize,
352 crp: crate::tools::CrpMode,
353 respect_gitignore: bool,
354 allow_secret_paths: bool,
355) -> Result<ToolOutput, ErrorData> {
356 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
357
358 let search_result = tokio::task::block_in_place(|| {
359 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
360 crate::tools::ctx_search::handle(
361 pattern,
362 path,
363 include,
364 max,
365 crp,
366 respect_gitignore,
367 allow_secret_paths,
368 )
369 }));
370 match result {
371 Ok(r) => Ok(r),
372 Err(_) => Err("search task panicked"),
373 }
374 });
375
376 let outcome = match search_result {
377 Ok(r) => r,
378 Err(e) => {
379 return Err(ErrorData::internal_error(
380 format!("search task failed: {e}"),
381 None,
382 ));
383 }
384 };
385 let result = outcome.text;
386 let observed = outcome.observed_tokens;
389
390 if result.starts_with("ERROR:") {
391 return Err(ErrorData::invalid_params(result, None));
392 }
393
394 let sent = crate::core::tokens::count_tokens(&result);
395 let saved = observed.saturating_sub(sent);
396 let final_out = crate::core::protocol::append_savings(&result, observed, sent);
397 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
400
401 Ok(ToolOutput {
402 text: final_out,
403 original_tokens: observed,
404 saved_tokens: saved,
405 mode: None,
406 path: Some(path.to_string()),
407 changed: false,
408 shell_outcome: None,
409 })
410}
411
412fn ext_to_include(ext: &str) -> String {
420 if ext.contains(['*', '{', '?', '/']) {
421 return ext.to_string();
422 }
423 let bare = ext.strip_prefix('.').unwrap_or(ext);
424 format!("*.{bare}")
425}
426
427#[cfg(test)]
428mod tests {
429 use super::{SearchAction, ext_to_include};
430 use serde_json::{Map, Value, json};
431
432 fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
433 pairs
434 .iter()
435 .cloned()
436 .map(|(k, v)| (k.to_string(), v))
437 .collect()
438 }
439
440 #[test]
441 fn explicit_action_selects_engine() {
442 assert_eq!(
444 SearchAction::resolve(&args(&[("action", json!("semantic"))])),
445 SearchAction::Semantic
446 );
447 assert_eq!(
448 SearchAction::resolve(&args(&[("action", json!("symbol"))])),
449 SearchAction::Symbol
450 );
451 assert_eq!(
452 SearchAction::resolve(&args(&[("action", json!("grep"))])),
453 SearchAction::Regex
454 );
455 assert_eq!(
456 SearchAction::resolve(&args(&[("action", json!("related"))])),
457 SearchAction::FindRelated
458 );
459 assert_eq!(
460 SearchAction::resolve(&args(&[("action", json!("reindex"))])),
461 SearchAction::Reindex
462 );
463 }
464
465 #[test]
466 fn action_inferred_from_fields_for_backward_compat() {
467 assert_eq!(
469 SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
470 SearchAction::Regex
471 );
472 assert_eq!(
473 SearchAction::resolve(&args(&[("query", json!("user auth"))])),
474 SearchAction::Semantic
475 );
476 assert_eq!(
477 SearchAction::resolve(&args(&[("name", json!("handle"))])),
478 SearchAction::Symbol
479 );
480 assert_eq!(
481 SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
482 SearchAction::FindRelated
483 );
484 }
485
486 #[test]
487 fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
488 assert_eq!(
490 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
491 SearchAction::Regex
492 );
493 assert_eq!(
495 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
496 SearchAction::Symbol
497 );
498 assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
500 }
501
502 #[test]
503 fn ext_alias_bare_extension_becomes_glob() {
504 assert_eq!(ext_to_include("rs"), "*.rs");
505 assert_eq!(ext_to_include("ts"), "*.ts");
506 }
507
508 #[test]
509 fn ext_alias_strips_leading_dot() {
510 assert_eq!(ext_to_include(".rs"), "*.rs");
511 assert_eq!(ext_to_include(".tsx"), "*.tsx");
512 }
513
514 #[test]
515 fn ext_alias_passes_through_glob_like_values() {
516 assert_eq!(ext_to_include("*.rs"), "*.rs");
518 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
519 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
520 }
521}