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
109fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
111 let pattern = get_str(args, "pattern")
112 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
113 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
114 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
115 let include =
118 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
119 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
120 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
121 let anchored = get_bool(args, "anchored").unwrap_or(false);
123
124 if no_gitignore
125 && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
126 {
127 return Ok(ToolOutput::simple(e));
128 }
129
130 let crp = ctx.crp_mode;
131 let respect = !no_gitignore;
132 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
133
134 if !resolved.is_multi {
135 return search_single(
136 &pattern,
137 &resolved.roots[0],
138 include.as_deref(),
139 max,
140 crp,
141 respect,
142 allow_secret_paths,
143 anchored,
144 );
145 }
146
147 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
148 let per_root_max = (max / resolved.roots.len()).max(5);
149 let mut combined = String::new();
150 let mut total_observed: usize = 0;
151 let mut total_sent: usize = 0;
152
153 for root in &resolved.roots {
154 let search_result = tokio::task::block_in_place(|| {
155 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
156 crate::tools::ctx_search::handle(
157 &pattern,
158 root,
159 include.as_deref(),
160 per_root_max,
161 crp,
162 respect,
163 allow_secret_paths,
164 anchored,
165 )
166 }))
167 .ok()
168 });
169
170 let Some(outcome) = search_result else {
171 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
172 continue;
173 };
174 let result = outcome.text;
175
176 if result.trim().is_empty() {
177 continue;
178 }
179
180 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
181
182 if result.starts_with("ERROR:") {
183 continue;
184 }
185
186 total_observed += outcome.observed_tokens;
187 total_sent += crate::core::tokens::count_tokens(&result);
188 }
189
190 if combined.is_empty() {
191 combined = "No matches found across any root.".to_string();
192 }
193
194 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
199 let saved = total_observed.saturating_sub(total_sent);
200 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
204
205 Ok(ToolOutput {
206 text: final_out,
207 original_tokens: total_observed,
208 saved_tokens: saved,
209 mode: None,
210 path: None,
211 changed: false,
212 shell_outcome: None,
213 })
214}
215
216fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
219 if let Some(p) = ctx.resolved_path("path") {
220 Ok(p.to_string())
221 } else if let Some(err) = ctx.path_error("path") {
222 Err(ErrorData::invalid_params(format!("path: {err}"), None))
223 } else {
224 Ok(ctx.project_root.clone())
225 }
226}
227
228fn prime_bm25_cache(ctx: &ToolContext) {
231 if let Some(ref cache) = ctx.bm25_cache {
232 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
233 }
234}
235
236fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
238 let query = get_str(args, "query")
239 .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
240 let path = resolve_path_or_root(ctx)?;
241 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
242 let mode = get_str(args, "mode");
243 let languages = get_str_array(args, "languages");
244 let path_glob = get_str(args, "path_glob");
245 let workspace = get_bool(args, "workspace").unwrap_or(false);
246 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
247 prime_bm25_cache(ctx);
248
249 let result = tokio::task::block_in_place(|| {
250 crate::tools::ctx_semantic_search::handle(
251 &query,
252 &path,
253 top_k,
254 ctx.crp_mode,
255 languages.as_deref(),
256 path_glob.as_deref(),
257 mode.as_deref(),
258 Some(workspace),
259 Some(artifacts),
260 )
261 });
262 Ok(semantic_output(result))
263}
264
265fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
269 if let Some(handle) = get_str(args, "handle") {
270 let (result, original) =
271 crate::tools::ctx_symbol::render_by_handle(&handle, &ctx.project_root);
272 let sent = crate::core::tokens::count_tokens(&result);
273 return Ok(ToolOutput {
274 text: result,
275 original_tokens: original,
276 saved_tokens: original.saturating_sub(sent),
277 mode: Some("handle".to_string()),
278 path: None,
279 changed: false,
280 shell_outcome: None,
281 });
282 }
283
284 let name = get_str(args, "name").ok_or_else(|| {
285 ErrorData::invalid_params("name or handle is required for action=symbol", None)
286 })?;
287 let file = get_str(args, "file");
288 let kind = get_str(args, "kind");
289
290 let (result, original) = crate::tools::ctx_symbol::handle(
291 &name,
292 file.as_deref(),
293 kind.as_deref(),
294 &ctx.project_root,
295 );
296 let sent = crate::core::tokens::count_tokens(&result);
297 Ok(ToolOutput {
298 text: result,
299 original_tokens: original,
300 saved_tokens: original.saturating_sub(sent),
301 mode: kind,
302 path: file,
303 changed: false,
304 shell_outcome: None,
305 })
306}
307
308fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
310 let path = resolve_path_or_root(ctx)?;
311 let workspace = get_bool(args, "workspace").unwrap_or(false);
312 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
313 prime_bm25_cache(ctx);
314
315 let result = tokio::task::block_in_place(|| {
316 if artifacts {
317 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
318 } else {
319 crate::tools::ctx_semantic_search::handle_reindex(&path)
320 }
321 });
322 Ok(semantic_output(result))
323}
324
325fn handle_find_related(
327 args: &Map<String, Value>,
328 ctx: &ToolContext,
329) -> Result<ToolOutput, ErrorData> {
330 let path = resolve_path_or_root(ctx)?;
331 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
332 let fp = get_str(args, "file_path").unwrap_or_default();
333 let line = get_int(args, "line").unwrap_or(1) as usize;
334 if fp.is_empty() {
335 return Err(ErrorData::invalid_params(
336 "find_related requires file_path and line",
337 None,
338 ));
339 }
340 prime_bm25_cache(ctx);
341
342 let result = tokio::task::block_in_place(|| {
343 crate::tools::ctx_semantic_search::handle_find_related(
344 &fp,
345 line,
346 &path,
347 top_k,
348 ctx.crp_mode,
349 )
350 });
351 Ok(semantic_output(result))
352}
353
354fn semantic_output(text: String) -> ToolOutput {
357 ToolOutput {
358 text,
359 original_tokens: 0,
360 saved_tokens: 0,
361 mode: Some("semantic".to_string()),
362 path: None,
363 changed: false,
364 shell_outcome: None,
365 }
366}
367
368#[allow(clippy::too_many_arguments)]
369fn search_single(
370 pattern: &str,
371 path: &str,
372 include: Option<&str>,
373 max: usize,
374 crp: crate::tools::CrpMode,
375 respect_gitignore: bool,
376 allow_secret_paths: bool,
377 anchored: bool,
378) -> Result<ToolOutput, ErrorData> {
379 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
380
381 let search_result = tokio::task::block_in_place(|| {
382 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
383 crate::tools::ctx_search::handle(
384 pattern,
385 path,
386 include,
387 max,
388 crp,
389 respect_gitignore,
390 allow_secret_paths,
391 anchored,
392 )
393 }));
394 match result {
395 Ok(r) => Ok(r),
396 Err(_) => Err("search task panicked"),
397 }
398 });
399
400 let outcome = match search_result {
401 Ok(r) => r,
402 Err(e) => {
403 return Err(ErrorData::internal_error(
404 format!("search task failed: {e}"),
405 None,
406 ));
407 }
408 };
409 let result = outcome.text;
410 let observed = outcome.observed_tokens;
413
414 if result.starts_with("ERROR:") {
415 return Err(ErrorData::invalid_params(result, None));
416 }
417
418 let sent = crate::core::tokens::count_tokens(&result);
419 let saved = observed.saturating_sub(sent);
420 let final_out = crate::core::protocol::append_savings(&result, observed, sent);
421 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
424
425 Ok(ToolOutput {
426 text: final_out,
427 original_tokens: observed,
428 saved_tokens: saved,
429 mode: None,
430 path: Some(path.to_string()),
431 changed: false,
432 shell_outcome: None,
433 })
434}
435
436fn ext_to_include(ext: &str) -> String {
444 if ext.contains(['*', '{', '?', '/']) {
445 return ext.to_string();
446 }
447 let bare = ext.strip_prefix('.').unwrap_or(ext);
448 format!("*.{bare}")
449}
450
451#[cfg(test)]
452mod tests {
453 use super::{SearchAction, ext_to_include};
454 use serde_json::{Map, Value, json};
455
456 fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
457 pairs
458 .iter()
459 .cloned()
460 .map(|(k, v)| (k.to_string(), v))
461 .collect()
462 }
463
464 #[test]
465 fn explicit_action_selects_engine() {
466 assert_eq!(
468 SearchAction::resolve(&args(&[("action", json!("semantic"))])),
469 SearchAction::Semantic
470 );
471 assert_eq!(
472 SearchAction::resolve(&args(&[("action", json!("symbol"))])),
473 SearchAction::Symbol
474 );
475 assert_eq!(
476 SearchAction::resolve(&args(&[("action", json!("grep"))])),
477 SearchAction::Regex
478 );
479 assert_eq!(
480 SearchAction::resolve(&args(&[("action", json!("related"))])),
481 SearchAction::FindRelated
482 );
483 assert_eq!(
484 SearchAction::resolve(&args(&[("action", json!("reindex"))])),
485 SearchAction::Reindex
486 );
487 }
488
489 #[test]
490 fn action_inferred_from_fields_for_backward_compat() {
491 assert_eq!(
493 SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
494 SearchAction::Regex
495 );
496 assert_eq!(
497 SearchAction::resolve(&args(&[("query", json!("user auth"))])),
498 SearchAction::Semantic
499 );
500 assert_eq!(
501 SearchAction::resolve(&args(&[("name", json!("handle"))])),
502 SearchAction::Symbol
503 );
504 assert_eq!(
505 SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
506 SearchAction::FindRelated
507 );
508 }
509
510 #[test]
511 fn handle_infers_symbol_action() {
512 assert_eq!(
514 SearchAction::resolve(&args(&[("handle", json!("src/lib.rs#Config::load@L22"))])),
515 SearchAction::Symbol
516 );
517 }
518
519 #[test]
520 fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
521 assert_eq!(
523 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
524 SearchAction::Regex
525 );
526 assert_eq!(
528 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
529 SearchAction::Symbol
530 );
531 assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
533 }
534
535 #[test]
536 fn ext_alias_bare_extension_becomes_glob() {
537 assert_eq!(ext_to_include("rs"), "*.rs");
538 assert_eq!(ext_to_include("ts"), "*.ts");
539 }
540
541 #[test]
542 fn ext_alias_strips_leading_dot() {
543 assert_eq!(ext_to_include(".rs"), "*.rs");
544 assert_eq!(ext_to_include(".tsx"), "*.tsx");
545 }
546
547 #[test]
548 fn ext_alias_passes_through_glob_like_values() {
549 assert_eq!(ext_to_include("*.rs"), "*.rs");
551 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
552 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
553 }
554}