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("handle") {
40 Self::Symbol
41 } else if args.contains_key("pattern") {
42 Self::Regex
43 } else if args.contains_key("name") {
44 Self::Symbol
45 } else if args.contains_key("file_path") && args.contains_key("line") {
46 Self::FindRelated
47 } else if args.contains_key("query") {
48 Self::Semantic
49 } else {
50 Self::Regex
51 }
52 }
53}
54
55impl McpTool for CtxSearchTool {
56 fn name(&self) -> &'static str {
57 "ctx_search"
58 }
59
60 fn tool_def(&self) -> Tool {
61 tool_def(
62 "ctx_search",
63 "Search code; `action` picks the engine (default regex). \
64 regex(pattern) | semantic(query, by meaning) | symbol(name, AST-exact; \
65 or handle=path#name@Lline) | reindex | find_related(file_path,line). \
66 anchored=true tags hits for ctx_patch. queries:[{pattern}] for batch. Run ctx_compose FIRST.",
67 json!({
68 "type": "object",
69 "properties": {
70 "action": {
71 "type": "string",
72 "enum": ["regex", "semantic", "symbol", "reindex", "find_related"]
73 },
74 "pattern": { "type": "string" },
75 "query": { "type": "string" },
76 "name": { "type": "string" },
77 "handle": { "type": "string", "description": "path#name@Lline (exact, stable)" },
78 "path": { "type": "string", "description": "Scope dir/root" },
79 "paths": { "type": "array", "items": { "type": "string" } },
80 "include": { "type": "string", "description": "Glob, e.g. *.rs" },
81 "exclude": { "type": "string" },
82 "exclude_pattern": { "type": "string", "description": "drop lines matching (grep -v)" },
83 "anchored": { "type": "boolean" },
84 "max_results": { "type": "integer" },
85 "top_k": { "type": "integer" },
86 "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
87 "file": { "type": "string" },
88 "kind": { "type": "string", "description": "fn|struct|class|trait|enum" },
89 "file_path": { "type": "string" },
90 "line": { "type": "integer" },
91 "queries": {
92 "type": "array",
93 "items": { "type": "object" },
94 "description": "[{pattern,include?,exclude?}] batch"
95 }
96 }
97 }),
98 )
99 }
100
101 fn handle(
102 &self,
103 args: &Map<String, Value>,
104 ctx: &ToolContext,
105 ) -> Result<ToolOutput, ErrorData> {
106 match SearchAction::resolve(args) {
107 SearchAction::Regex => handle_regex(args, ctx),
108 SearchAction::Semantic => handle_semantic(args, ctx),
109 SearchAction::Symbol => handle_symbol(args, ctx),
110 SearchAction::Reindex => handle_reindex(args, ctx),
111 SearchAction::FindRelated => handle_find_related(args, ctx),
112 }
113 }
114}
115
116const KNOWN_KEYS: &[&str] = &[
119 "action",
120 "pattern",
121 "query",
122 "name",
123 "handle",
124 "path",
125 "paths",
126 "include",
127 "exclude",
128 "exclude_pattern",
129 "ext",
130 "anchored",
131 "max_results",
132 "top_k",
133 "mode",
134 "file",
135 "kind",
136 "file_path",
137 "line",
138 "languages",
139 "path_glob",
140 "workspace",
141 "artifacts",
142 "ignore_gitignore",
143];
144
145fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
147 if let Some(Value::Array(queries)) = args.get("queries") {
150 return handle_batch_queries(queries, args, ctx);
151 }
152 let pattern = get_str(args, "pattern")
156 .or_else(|| {
157 args.iter()
158 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
159 .and_then(|(_, v)| v.as_str().map(String::from))
160 })
161 .ok_or_else(|| {
162 ErrorData::invalid_params(
163 "pattern is required. Example: ctx_search(pattern=\"fn main\", path=\"/src\")",
164 None,
165 )
166 })?;
167 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
168 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
169 let include =
172 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
173 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
174 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
175 let anchored = get_bool(args, "anchored").unwrap_or(false);
177 let exclude = get_str(args, "exclude");
180 let exclude_pattern = get_str(args, "exclude_pattern");
181
182 if no_gitignore
183 && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
184 {
185 return Ok(ToolOutput::simple(e));
186 }
187
188 let crp = ctx.crp_mode;
189 let respect = !no_gitignore;
190 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
191
192 if !resolved.is_multi {
193 return search_single(
194 &pattern,
195 &resolved.roots[0],
196 include.as_deref(),
197 max,
198 crp,
199 respect,
200 allow_secret_paths,
201 anchored,
202 exclude.as_deref(),
203 exclude_pattern.as_deref(),
204 );
205 }
206
207 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
208 let per_root_max = (max / resolved.roots.len()).max(5);
209 let mut combined = String::new();
210 let mut total_observed: usize = 0;
211 let mut total_sent: usize = 0;
212
213 for root in &resolved.roots {
214 let search_result = tokio::task::block_in_place(|| {
215 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
216 crate::tools::ctx_search::handle_filtered(
217 &pattern,
218 root,
219 include.as_deref(),
220 per_root_max,
221 crp,
222 respect,
223 allow_secret_paths,
224 anchored,
225 exclude.as_deref(),
226 exclude_pattern.as_deref(),
227 )
228 }))
229 .ok()
230 });
231
232 let Some(outcome) = search_result else {
233 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
234 continue;
235 };
236 let result = outcome.text;
237
238 if result.trim().is_empty() {
239 continue;
240 }
241
242 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
243
244 if result.starts_with("ERROR:") {
245 continue;
246 }
247
248 total_observed += outcome.observed_tokens;
249 total_sent += crate::core::tokens::count_tokens(&result);
250 }
251
252 if combined.is_empty() {
253 combined = "No matches found across any root.".to_string();
254 }
255
256 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
261 let saved = total_observed.saturating_sub(total_sent);
262 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
266
267 Ok(ToolOutput {
268 text: final_out,
269 original_tokens: total_observed,
270 saved_tokens: saved,
271 mode: None,
272 path: None,
273 changed: false,
274 shell_outcome: None,
275 content_blocks: None,
276 })
277}
278
279fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
282 if let Some(p) = ctx.resolved_path("path") {
283 Ok(p.to_string())
284 } else if let Some(err) = ctx.path_error("path") {
285 Err(ErrorData::invalid_params(format!("path: {err}"), None))
286 } else {
287 Ok(ctx.project_root.clone())
288 }
289}
290
291fn prime_bm25_cache(ctx: &ToolContext) {
294 if let Some(ref cache) = ctx.bm25_cache {
295 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
296 }
297}
298
299fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
301 let query = get_str(args, "query")
302 .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
303 let path = resolve_path_or_root(ctx)?;
304 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
305 let mode = get_str(args, "mode");
306 let languages = get_str_array(args, "languages");
307 let path_glob = get_str(args, "path_glob");
308 let workspace = get_bool(args, "workspace").unwrap_or(false);
309 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
310 prime_bm25_cache(ctx);
311
312 let result = tokio::task::block_in_place(|| {
313 crate::tools::ctx_semantic_search::handle(
314 &query,
315 &path,
316 top_k,
317 ctx.crp_mode,
318 languages.as_deref(),
319 path_glob.as_deref(),
320 mode.as_deref(),
321 Some(workspace),
322 Some(artifacts),
323 )
324 });
325 Ok(semantic_output(result))
326}
327
328fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
332 if let Some(handle) = get_str(args, "handle") {
333 let (result, original) =
334 crate::tools::ctx_symbol::render_by_handle(&handle, &ctx.project_root);
335 let sent = crate::core::tokens::count_tokens(&result);
336 return Ok(ToolOutput {
337 text: result,
338 original_tokens: original,
339 saved_tokens: original.saturating_sub(sent),
340 mode: Some("handle".to_string()),
341 path: None,
342 changed: false,
343 shell_outcome: None,
344 content_blocks: None,
345 });
346 }
347
348 let name = get_str(args, "name").ok_or_else(|| {
349 ErrorData::invalid_params("name or handle is required for action=symbol", None)
350 })?;
351 let file = get_str(args, "file");
352 let kind = get_str(args, "kind");
353
354 let (result, original) = crate::tools::ctx_symbol::handle(
355 &name,
356 file.as_deref(),
357 kind.as_deref(),
358 &ctx.project_root,
359 );
360 let sent = crate::core::tokens::count_tokens(&result);
361 Ok(ToolOutput {
362 text: result,
363 original_tokens: original,
364 saved_tokens: original.saturating_sub(sent),
365 mode: kind,
366 path: file,
367 changed: false,
368 shell_outcome: None,
369 content_blocks: None,
370 })
371}
372
373fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
375 let path = resolve_path_or_root(ctx)?;
376 let workspace = get_bool(args, "workspace").unwrap_or(false);
377 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
378 prime_bm25_cache(ctx);
379
380 let result = tokio::task::block_in_place(|| {
381 if artifacts {
382 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
383 } else {
384 crate::tools::ctx_semantic_search::handle_reindex(&path)
385 }
386 });
387 Ok(semantic_output(result))
388}
389
390fn handle_find_related(
392 args: &Map<String, Value>,
393 ctx: &ToolContext,
394) -> Result<ToolOutput, ErrorData> {
395 let path = resolve_path_or_root(ctx)?;
396 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
397 let fp = get_str(args, "file_path").unwrap_or_default();
398 let line = get_int(args, "line").unwrap_or(1) as usize;
399 if fp.is_empty() {
400 return Err(ErrorData::invalid_params(
401 "find_related requires file_path and line",
402 None,
403 ));
404 }
405 prime_bm25_cache(ctx);
406
407 let result = tokio::task::block_in_place(|| {
408 crate::tools::ctx_semantic_search::handle_find_related(
409 &fp,
410 line,
411 &path,
412 top_k,
413 ctx.crp_mode,
414 )
415 });
416 Ok(semantic_output(result))
417}
418
419fn semantic_output(text: String) -> ToolOutput {
422 ToolOutput {
423 text,
424 original_tokens: 0,
425 saved_tokens: 0,
426 mode: Some("semantic".to_string()),
427 path: None,
428 changed: false,
429 shell_outcome: None,
430 content_blocks: None,
431 }
432}
433
434#[allow(clippy::too_many_arguments)]
435fn search_single(
436 pattern: &str,
437 path: &str,
438 include: Option<&str>,
439 max: usize,
440 crp: crate::tools::CrpMode,
441 respect_gitignore: bool,
442 allow_secret_paths: bool,
443 anchored: bool,
444 exclude: Option<&str>,
445 exclude_pattern: Option<&str>,
446) -> Result<ToolOutput, ErrorData> {
447 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
448
449 let search_result = tokio::task::block_in_place(|| {
450 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
451 crate::tools::ctx_search::handle_filtered(
452 pattern,
453 path,
454 include,
455 max,
456 crp,
457 respect_gitignore,
458 allow_secret_paths,
459 anchored,
460 exclude,
461 exclude_pattern,
462 )
463 }));
464 match result {
465 Ok(r) => Ok(r),
466 Err(_) => Err("search task panicked"),
467 }
468 });
469
470 let outcome = match search_result {
471 Ok(r) => r,
472 Err(e) => {
473 return Err(ErrorData::internal_error(
474 format!("search task failed: {e}"),
475 None,
476 ));
477 }
478 };
479 let result = outcome.text;
480 let observed = outcome.observed_tokens;
483
484 if result.starts_with("ERROR:") {
485 return Err(ErrorData::invalid_params(result, None));
486 }
487
488 let sent = crate::core::tokens::count_tokens(&result);
489 let saved = observed.saturating_sub(sent);
490 let final_out = crate::core::protocol::append_savings(&result, observed, sent);
491 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
494
495 Ok(ToolOutput {
496 text: final_out,
497 original_tokens: observed,
498 saved_tokens: saved,
499 mode: None,
500 path: Some(path.to_string()),
501 changed: false,
502 shell_outcome: None,
503 content_blocks: None,
504 })
505}
506
507fn handle_batch_queries(
516 queries: &[Value],
517 args: &Map<String, Value>,
518 ctx: &ToolContext,
519) -> Result<ToolOutput, ErrorData> {
520 if queries.is_empty() {
521 return Err(ErrorData::invalid_params(
522 "queries array must not be empty",
523 None,
524 ));
525 }
526 if queries.len() > 10 {
527 return Err(ErrorData::invalid_params(
528 "queries array limited to 10 entries",
529 None,
530 ));
531 }
532
533 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
534 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
535 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
536 let anchored = get_bool(args, "anchored").unwrap_or(false);
537 let crp = ctx.crp_mode;
538 let respect = !no_gitignore;
539 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
540 let root = &resolved.roots[0];
541 let global_max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
542 let per_query_max = (global_max / queries.len()).max(5);
543
544 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
545 let mut combined = String::new();
546 let mut total_observed: usize = 0;
547 let mut total_sent: usize = 0;
548
549 for (idx, q) in queries.iter().enumerate() {
550 let Some(obj) = q.as_object() else {
551 combined.push_str(&format!(
552 "── query {} ──\nERROR: expected object\n\n",
553 idx + 1
554 ));
555 continue;
556 };
557 let Some(pattern) = get_str(obj, "pattern") else {
558 combined.push_str(&format!(
559 "── query {} ──\nERROR: pattern required\n\n",
560 idx + 1
561 ));
562 continue;
563 };
564 let include =
565 get_str(obj, "include").or_else(|| get_str(obj, "ext").map(|e| ext_to_include(&e)));
566 let exclude = get_str(obj, "exclude");
567 let exclude_pattern = get_str(obj, "exclude_pattern");
568
569 let search_result = tokio::task::block_in_place(|| {
570 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
571 crate::tools::ctx_search::handle_filtered(
572 &pattern,
573 root,
574 include.as_deref(),
575 per_query_max,
576 crp,
577 respect,
578 allow_secret_paths,
579 anchored,
580 exclude.as_deref(),
581 exclude_pattern.as_deref(),
582 )
583 }))
584 .ok()
585 });
586
587 let label = if queries.len() > 1 {
588 format!(
589 "── query {}: '{}' ──\n",
590 idx + 1,
591 truncate_query(&pattern, 40)
592 )
593 } else {
594 String::new()
595 };
596
597 let Some(outcome) = search_result else {
598 combined.push_str(&format!("{label}ERROR: search panicked\n\n"));
599 continue;
600 };
601
602 if !outcome.text.trim().is_empty() {
603 combined.push_str(&format!("{label}{}\n\n", outcome.text));
604 total_observed += outcome.observed_tokens;
605 total_sent += crate::core::tokens::count_tokens(&outcome.text);
606 }
607 }
608
609 if combined.is_empty() {
610 combined = "No matches found for any query.".to_string();
611 }
612
613 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
614 let saved = total_observed.saturating_sub(total_sent);
615 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
616
617 Ok(ToolOutput {
618 text: final_out,
619 original_tokens: total_observed,
620 saved_tokens: saved,
621 mode: None,
622 path: None,
623 changed: false,
624 shell_outcome: None,
625 content_blocks: None,
626 })
627}
628
629fn truncate_query(q: &str, max: usize) -> String {
631 if q.len() <= max {
632 q.to_string()
633 } else {
634 format!("{}...", &q[..q.floor_char_boundary(max)])
635 }
636}
637
638fn ext_to_include(ext: &str) -> String {
639 if ext.contains(['*', '{', '?', '/']) {
640 return ext.to_string();
641 }
642 let bare = ext.strip_prefix('.').unwrap_or(ext);
643 format!("*.{bare}")
644}
645
646#[cfg(test)]
647mod tests {
648 use super::{SearchAction, ext_to_include};
649 use serde_json::{Map, Value, json};
650
651 fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
652 pairs
653 .iter()
654 .cloned()
655 .map(|(k, v)| (k.to_string(), v))
656 .collect()
657 }
658
659 #[test]
660 fn explicit_action_selects_engine() {
661 assert_eq!(
663 SearchAction::resolve(&args(&[("action", json!("semantic"))])),
664 SearchAction::Semantic
665 );
666 assert_eq!(
667 SearchAction::resolve(&args(&[("action", json!("symbol"))])),
668 SearchAction::Symbol
669 );
670 assert_eq!(
671 SearchAction::resolve(&args(&[("action", json!("grep"))])),
672 SearchAction::Regex
673 );
674 assert_eq!(
675 SearchAction::resolve(&args(&[("action", json!("related"))])),
676 SearchAction::FindRelated
677 );
678 assert_eq!(
679 SearchAction::resolve(&args(&[("action", json!("reindex"))])),
680 SearchAction::Reindex
681 );
682 }
683
684 #[test]
685 fn action_inferred_from_fields_for_backward_compat() {
686 assert_eq!(
688 SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
689 SearchAction::Regex
690 );
691 assert_eq!(
692 SearchAction::resolve(&args(&[("query", json!("user auth"))])),
693 SearchAction::Semantic
694 );
695 assert_eq!(
696 SearchAction::resolve(&args(&[("name", json!("handle"))])),
697 SearchAction::Symbol
698 );
699 assert_eq!(
700 SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
701 SearchAction::FindRelated
702 );
703 }
704
705 #[test]
706 fn handle_infers_symbol_action() {
707 assert_eq!(
709 SearchAction::resolve(&args(&[("handle", json!("src/lib.rs#Config::load@L22"))])),
710 SearchAction::Symbol
711 );
712 }
713
714 #[test]
715 fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
716 assert_eq!(
718 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
719 SearchAction::Regex
720 );
721 assert_eq!(
723 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
724 SearchAction::Symbol
725 );
726 assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
728 }
729
730 #[test]
731 fn ext_alias_bare_extension_becomes_glob() {
732 assert_eq!(ext_to_include("rs"), "*.rs");
733 assert_eq!(ext_to_include("ts"), "*.ts");
734 }
735
736 #[test]
737 fn ext_alias_strips_leading_dot() {
738 assert_eq!(ext_to_include(".rs"), "*.rs");
739 assert_eq!(ext_to_include(".tsx"), "*.tsx");
740 }
741
742 #[test]
743 fn ext_alias_passes_through_glob_like_values() {
744 assert_eq!(ext_to_include("*.rs"), "*.rs");
746 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
747 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
748 }
749
750 #[test]
751 fn lenient_fallback_uses_unknown_string_key_as_pattern() {
752 use super::{KNOWN_KEYS, get_str};
753
754 let a = args(&[("search_term", json!("fn main"))]);
757 let pattern = get_str(&a, "pattern").or_else(|| {
758 a.iter()
759 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
760 .and_then(|(_, v)| v.as_str().map(String::from))
761 });
762 assert_eq!(pattern, Some("fn main".to_string()));
763 }
764
765 #[test]
766 fn lenient_fallback_does_not_grab_known_keys() {
767 use super::{KNOWN_KEYS, get_str};
768
769 let a = args(&[("path", json!("/src")), ("max_results", json!(10))]);
772 let pattern = get_str(&a, "pattern").or_else(|| {
773 a.iter()
774 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
775 .and_then(|(_, v)| v.as_str().map(String::from))
776 });
777 assert_eq!(pattern, None);
778 }
779}