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. 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 "anchored": { "type": "boolean" },
82 "max_results": { "type": "integer" },
83 "top_k": { "type": "integer" },
84 "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
85 "file": { "type": "string" },
86 "kind": { "type": "string", "description": "fn|struct|class|trait|enum" },
87 "file_path": { "type": "string" },
88 "line": { "type": "integer" }
89 }
90 }),
91 )
92 }
93
94 fn handle(
95 &self,
96 args: &Map<String, Value>,
97 ctx: &ToolContext,
98 ) -> Result<ToolOutput, ErrorData> {
99 match SearchAction::resolve(args) {
100 SearchAction::Regex => handle_regex(args, ctx),
101 SearchAction::Semantic => handle_semantic(args, ctx),
102 SearchAction::Symbol => handle_symbol(args, ctx),
103 SearchAction::Reindex => handle_reindex(args, ctx),
104 SearchAction::FindRelated => handle_find_related(args, ctx),
105 }
106 }
107}
108
109const KNOWN_KEYS: &[&str] = &[
112 "action",
113 "pattern",
114 "query",
115 "name",
116 "handle",
117 "path",
118 "paths",
119 "include",
120 "ext",
121 "anchored",
122 "max_results",
123 "top_k",
124 "mode",
125 "file",
126 "kind",
127 "file_path",
128 "line",
129 "languages",
130 "path_glob",
131 "workspace",
132 "artifacts",
133 "ignore_gitignore",
134];
135
136fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
138 let pattern = get_str(args, "pattern")
142 .or_else(|| {
143 args.iter()
144 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
145 .and_then(|(_, v)| v.as_str().map(String::from))
146 })
147 .ok_or_else(|| {
148 ErrorData::invalid_params(
149 "pattern is required. Example: ctx_search(pattern=\"fn main\", path=\"/src\")",
150 None,
151 )
152 })?;
153 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
154 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
155 let include =
158 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
159 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
160 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
161 let anchored = get_bool(args, "anchored").unwrap_or(false);
163
164 if no_gitignore
165 && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
166 {
167 return Ok(ToolOutput::simple(e));
168 }
169
170 let crp = ctx.crp_mode;
171 let respect = !no_gitignore;
172 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
173
174 if !resolved.is_multi {
175 return search_single(
176 &pattern,
177 &resolved.roots[0],
178 include.as_deref(),
179 max,
180 crp,
181 respect,
182 allow_secret_paths,
183 anchored,
184 );
185 }
186
187 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
188 let per_root_max = (max / resolved.roots.len()).max(5);
189 let mut combined = String::new();
190 let mut total_observed: usize = 0;
191 let mut total_sent: usize = 0;
192
193 for root in &resolved.roots {
194 let search_result = tokio::task::block_in_place(|| {
195 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
196 crate::tools::ctx_search::handle(
197 &pattern,
198 root,
199 include.as_deref(),
200 per_root_max,
201 crp,
202 respect,
203 allow_secret_paths,
204 anchored,
205 )
206 }))
207 .ok()
208 });
209
210 let Some(outcome) = search_result else {
211 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
212 continue;
213 };
214 let result = outcome.text;
215
216 if result.trim().is_empty() {
217 continue;
218 }
219
220 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
221
222 if result.starts_with("ERROR:") {
223 continue;
224 }
225
226 total_observed += outcome.observed_tokens;
227 total_sent += crate::core::tokens::count_tokens(&result);
228 }
229
230 if combined.is_empty() {
231 combined = "No matches found across any root.".to_string();
232 }
233
234 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
239 let saved = total_observed.saturating_sub(total_sent);
240 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
244
245 Ok(ToolOutput {
246 text: final_out,
247 original_tokens: total_observed,
248 saved_tokens: saved,
249 mode: None,
250 path: None,
251 changed: false,
252 shell_outcome: None,
253 })
254}
255
256fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
259 if let Some(p) = ctx.resolved_path("path") {
260 Ok(p.to_string())
261 } else if let Some(err) = ctx.path_error("path") {
262 Err(ErrorData::invalid_params(format!("path: {err}"), None))
263 } else {
264 Ok(ctx.project_root.clone())
265 }
266}
267
268fn prime_bm25_cache(ctx: &ToolContext) {
271 if let Some(ref cache) = ctx.bm25_cache {
272 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
273 }
274}
275
276fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
278 let query = get_str(args, "query")
279 .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
280 let path = resolve_path_or_root(ctx)?;
281 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
282 let mode = get_str(args, "mode");
283 let languages = get_str_array(args, "languages");
284 let path_glob = get_str(args, "path_glob");
285 let workspace = get_bool(args, "workspace").unwrap_or(false);
286 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
287 prime_bm25_cache(ctx);
288
289 let result = tokio::task::block_in_place(|| {
290 crate::tools::ctx_semantic_search::handle(
291 &query,
292 &path,
293 top_k,
294 ctx.crp_mode,
295 languages.as_deref(),
296 path_glob.as_deref(),
297 mode.as_deref(),
298 Some(workspace),
299 Some(artifacts),
300 )
301 });
302 Ok(semantic_output(result))
303}
304
305fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
309 if let Some(handle) = get_str(args, "handle") {
310 let (result, original) =
311 crate::tools::ctx_symbol::render_by_handle(&handle, &ctx.project_root);
312 let sent = crate::core::tokens::count_tokens(&result);
313 return Ok(ToolOutput {
314 text: result,
315 original_tokens: original,
316 saved_tokens: original.saturating_sub(sent),
317 mode: Some("handle".to_string()),
318 path: None,
319 changed: false,
320 shell_outcome: None,
321 });
322 }
323
324 let name = get_str(args, "name").ok_or_else(|| {
325 ErrorData::invalid_params("name or handle is required for action=symbol", None)
326 })?;
327 let file = get_str(args, "file");
328 let kind = get_str(args, "kind");
329
330 let (result, original) = crate::tools::ctx_symbol::handle(
331 &name,
332 file.as_deref(),
333 kind.as_deref(),
334 &ctx.project_root,
335 );
336 let sent = crate::core::tokens::count_tokens(&result);
337 Ok(ToolOutput {
338 text: result,
339 original_tokens: original,
340 saved_tokens: original.saturating_sub(sent),
341 mode: kind,
342 path: file,
343 changed: false,
344 shell_outcome: None,
345 })
346}
347
348fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
350 let path = resolve_path_or_root(ctx)?;
351 let workspace = get_bool(args, "workspace").unwrap_or(false);
352 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
353 prime_bm25_cache(ctx);
354
355 let result = tokio::task::block_in_place(|| {
356 if artifacts {
357 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
358 } else {
359 crate::tools::ctx_semantic_search::handle_reindex(&path)
360 }
361 });
362 Ok(semantic_output(result))
363}
364
365fn handle_find_related(
367 args: &Map<String, Value>,
368 ctx: &ToolContext,
369) -> Result<ToolOutput, ErrorData> {
370 let path = resolve_path_or_root(ctx)?;
371 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
372 let fp = get_str(args, "file_path").unwrap_or_default();
373 let line = get_int(args, "line").unwrap_or(1) as usize;
374 if fp.is_empty() {
375 return Err(ErrorData::invalid_params(
376 "find_related requires file_path and line",
377 None,
378 ));
379 }
380 prime_bm25_cache(ctx);
381
382 let result = tokio::task::block_in_place(|| {
383 crate::tools::ctx_semantic_search::handle_find_related(
384 &fp,
385 line,
386 &path,
387 top_k,
388 ctx.crp_mode,
389 )
390 });
391 Ok(semantic_output(result))
392}
393
394fn semantic_output(text: String) -> ToolOutput {
397 ToolOutput {
398 text,
399 original_tokens: 0,
400 saved_tokens: 0,
401 mode: Some("semantic".to_string()),
402 path: None,
403 changed: false,
404 shell_outcome: None,
405 }
406}
407
408#[allow(clippy::too_many_arguments)]
409fn search_single(
410 pattern: &str,
411 path: &str,
412 include: Option<&str>,
413 max: usize,
414 crp: crate::tools::CrpMode,
415 respect_gitignore: bool,
416 allow_secret_paths: bool,
417 anchored: bool,
418) -> Result<ToolOutput, ErrorData> {
419 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
420
421 let search_result = tokio::task::block_in_place(|| {
422 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
423 crate::tools::ctx_search::handle(
424 pattern,
425 path,
426 include,
427 max,
428 crp,
429 respect_gitignore,
430 allow_secret_paths,
431 anchored,
432 )
433 }));
434 match result {
435 Ok(r) => Ok(r),
436 Err(_) => Err("search task panicked"),
437 }
438 });
439
440 let outcome = match search_result {
441 Ok(r) => r,
442 Err(e) => {
443 return Err(ErrorData::internal_error(
444 format!("search task failed: {e}"),
445 None,
446 ));
447 }
448 };
449 let result = outcome.text;
450 let observed = outcome.observed_tokens;
453
454 if result.starts_with("ERROR:") {
455 return Err(ErrorData::invalid_params(result, None));
456 }
457
458 let sent = crate::core::tokens::count_tokens(&result);
459 let saved = observed.saturating_sub(sent);
460 let final_out = crate::core::protocol::append_savings(&result, observed, sent);
461 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
464
465 Ok(ToolOutput {
466 text: final_out,
467 original_tokens: observed,
468 saved_tokens: saved,
469 mode: None,
470 path: Some(path.to_string()),
471 changed: false,
472 shell_outcome: None,
473 })
474}
475
476fn ext_to_include(ext: &str) -> String {
484 if ext.contains(['*', '{', '?', '/']) {
485 return ext.to_string();
486 }
487 let bare = ext.strip_prefix('.').unwrap_or(ext);
488 format!("*.{bare}")
489}
490
491#[cfg(test)]
492mod tests {
493 use super::{SearchAction, ext_to_include};
494 use serde_json::{Map, Value, json};
495
496 fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
497 pairs
498 .iter()
499 .cloned()
500 .map(|(k, v)| (k.to_string(), v))
501 .collect()
502 }
503
504 #[test]
505 fn explicit_action_selects_engine() {
506 assert_eq!(
508 SearchAction::resolve(&args(&[("action", json!("semantic"))])),
509 SearchAction::Semantic
510 );
511 assert_eq!(
512 SearchAction::resolve(&args(&[("action", json!("symbol"))])),
513 SearchAction::Symbol
514 );
515 assert_eq!(
516 SearchAction::resolve(&args(&[("action", json!("grep"))])),
517 SearchAction::Regex
518 );
519 assert_eq!(
520 SearchAction::resolve(&args(&[("action", json!("related"))])),
521 SearchAction::FindRelated
522 );
523 assert_eq!(
524 SearchAction::resolve(&args(&[("action", json!("reindex"))])),
525 SearchAction::Reindex
526 );
527 }
528
529 #[test]
530 fn action_inferred_from_fields_for_backward_compat() {
531 assert_eq!(
533 SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
534 SearchAction::Regex
535 );
536 assert_eq!(
537 SearchAction::resolve(&args(&[("query", json!("user auth"))])),
538 SearchAction::Semantic
539 );
540 assert_eq!(
541 SearchAction::resolve(&args(&[("name", json!("handle"))])),
542 SearchAction::Symbol
543 );
544 assert_eq!(
545 SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
546 SearchAction::FindRelated
547 );
548 }
549
550 #[test]
551 fn handle_infers_symbol_action() {
552 assert_eq!(
554 SearchAction::resolve(&args(&[("handle", json!("src/lib.rs#Config::load@L22"))])),
555 SearchAction::Symbol
556 );
557 }
558
559 #[test]
560 fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
561 assert_eq!(
563 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
564 SearchAction::Regex
565 );
566 assert_eq!(
568 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
569 SearchAction::Symbol
570 );
571 assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
573 }
574
575 #[test]
576 fn ext_alias_bare_extension_becomes_glob() {
577 assert_eq!(ext_to_include("rs"), "*.rs");
578 assert_eq!(ext_to_include("ts"), "*.ts");
579 }
580
581 #[test]
582 fn ext_alias_strips_leading_dot() {
583 assert_eq!(ext_to_include(".rs"), "*.rs");
584 assert_eq!(ext_to_include(".tsx"), "*.tsx");
585 }
586
587 #[test]
588 fn ext_alias_passes_through_glob_like_values() {
589 assert_eq!(ext_to_include("*.rs"), "*.rs");
591 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
592 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
593 }
594
595 #[test]
596 fn lenient_fallback_uses_unknown_string_key_as_pattern() {
597 use super::{KNOWN_KEYS, get_str};
598
599 let a = args(&[("search_term", json!("fn main"))]);
602 let pattern = get_str(&a, "pattern").or_else(|| {
603 a.iter()
604 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
605 .and_then(|(_, v)| v.as_str().map(String::from))
606 });
607 assert_eq!(pattern, Some("fn main".to_string()));
608 }
609
610 #[test]
611 fn lenient_fallback_does_not_grab_known_keys() {
612 use super::{KNOWN_KEYS, get_str};
613
614 let a = args(&[("path", json!("/src")), ("max_results", json!(10))]);
617 let pattern = get_str(&a, "pattern").or_else(|| {
618 a.iter()
619 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
620 .and_then(|(_, v)| v.as_str().map(String::from))
621 });
622 assert_eq!(pattern, None);
623 }
624}