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. regex (default): `pattern` exact, \
64 include='*.rs', paths=[..] multi-root. semantic: by meaning (BM25+embeddings), \
65 `query`, mode=bm25|dense|hybrid. symbol: one body by `name` (AST), file/kind narrow, \
66 or `handle`=path#name@Lline (exact, stable). reindex / find_related(file_path,line). \
67 anchored=true tags hits path:line:hh for ctx_patch. Run ctx_compose FIRST.",
68 json!({
69 "type": "object",
70 "properties": {
71 "action": {
72 "type": "string",
73 "enum": ["regex", "semantic", "symbol", "reindex", "find_related"]
74 },
75 "pattern": { "type": "string", "description": "Regex pattern" },
76 "query": { "type": "string", "description": "Meaning query" },
77 "name": { "type": "string", "description": "Symbol name" },
78 "handle": { "type": "string", "description": "Stable handle path#name@Lline (exact)" },
79 "path": { "type": "string", "description": "Scope dir/root" },
80 "paths": {
81 "type": "array",
82 "items": { "type": "string" },
83 "description": "Multi-root regex"
84 },
85 "include": { "type": "string", "description": "Glob, e.g. *.rs" },
86 "anchored": { "type": "boolean", "description": "Tag hits path:line:hh for ctx_patch" },
87 "max_results": { "type": "integer" },
88 "top_k": { "type": "integer" },
89 "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
90 "file": { "type": "string", "description": "Narrow to file" },
91 "kind": { "type": "string", "description": "fn|struct|class|trait|enum" },
92 "file_path": { "type": "string" },
93 "line": { "type": "integer" }
94 }
95 }),
96 )
97 }
98
99 fn handle(
100 &self,
101 args: &Map<String, Value>,
102 ctx: &ToolContext,
103 ) -> Result<ToolOutput, ErrorData> {
104 match SearchAction::resolve(args) {
105 SearchAction::Regex => handle_regex(args, ctx),
106 SearchAction::Semantic => handle_semantic(args, ctx),
107 SearchAction::Symbol => handle_symbol(args, ctx),
108 SearchAction::Reindex => handle_reindex(args, ctx),
109 SearchAction::FindRelated => handle_find_related(args, ctx),
110 }
111 }
112}
113
114fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
116 let pattern = get_str(args, "pattern")
117 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
118 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
119 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
120 let include =
123 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
124 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
125 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
126 let anchored = get_bool(args, "anchored").unwrap_or(false);
128
129 if no_gitignore
130 && let Err(e) = crate::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
131 {
132 return Ok(ToolOutput::simple(e));
133 }
134
135 let crp = ctx.crp_mode;
136 let respect = !no_gitignore;
137 let allow_secret_paths = crate::core::roles::active_role().io.allow_secret_paths;
138
139 if !resolved.is_multi {
140 return search_single(
141 &pattern,
142 &resolved.roots[0],
143 include.as_deref(),
144 max,
145 crp,
146 respect,
147 allow_secret_paths,
148 anchored,
149 );
150 }
151
152 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
153 let per_root_max = (max / resolved.roots.len()).max(5);
154 let mut combined = String::new();
155 let mut total_observed: usize = 0;
156 let mut total_sent: usize = 0;
157
158 for root in &resolved.roots {
159 let search_result = tokio::task::block_in_place(|| {
160 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
161 crate::tools::ctx_search::handle(
162 &pattern,
163 root,
164 include.as_deref(),
165 per_root_max,
166 crp,
167 respect,
168 allow_secret_paths,
169 anchored,
170 )
171 }))
172 .ok()
173 });
174
175 let Some(outcome) = search_result else {
176 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
177 continue;
178 };
179 let result = outcome.text;
180
181 if result.trim().is_empty() {
182 continue;
183 }
184
185 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
186
187 if result.starts_with("ERROR:") {
188 continue;
189 }
190
191 total_observed += outcome.observed_tokens;
192 total_sent += crate::core::tokens::count_tokens(&result);
193 }
194
195 if combined.is_empty() {
196 combined = "No matches found across any root.".to_string();
197 }
198
199 let final_out = crate::core::protocol::append_savings(&combined, total_observed, total_sent);
204 let saved = total_observed.saturating_sub(total_sent);
205 crate::core::savings_ledger::record_tool_event("ctx_search", total_observed, total_sent);
209
210 Ok(ToolOutput {
211 text: final_out,
212 original_tokens: total_observed,
213 saved_tokens: saved,
214 mode: None,
215 path: None,
216 changed: false,
217 shell_outcome: None,
218 })
219}
220
221fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
224 if let Some(p) = ctx.resolved_path("path") {
225 Ok(p.to_string())
226 } else if let Some(err) = ctx.path_error("path") {
227 Err(ErrorData::invalid_params(format!("path: {err}"), None))
228 } else {
229 Ok(ctx.project_root.clone())
230 }
231}
232
233fn prime_bm25_cache(ctx: &ToolContext) {
236 if let Some(ref cache) = ctx.bm25_cache {
237 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
238 }
239}
240
241fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
243 let query = get_str(args, "query")
244 .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
245 let path = resolve_path_or_root(ctx)?;
246 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
247 let mode = get_str(args, "mode");
248 let languages = get_str_array(args, "languages");
249 let path_glob = get_str(args, "path_glob");
250 let workspace = get_bool(args, "workspace").unwrap_or(false);
251 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
252 prime_bm25_cache(ctx);
253
254 let result = tokio::task::block_in_place(|| {
255 crate::tools::ctx_semantic_search::handle(
256 &query,
257 &path,
258 top_k,
259 ctx.crp_mode,
260 languages.as_deref(),
261 path_glob.as_deref(),
262 mode.as_deref(),
263 Some(workspace),
264 Some(artifacts),
265 )
266 });
267 Ok(semantic_output(result))
268}
269
270fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
274 if let Some(handle) = get_str(args, "handle") {
275 let (result, original) =
276 crate::tools::ctx_symbol::render_by_handle(&handle, &ctx.project_root);
277 let sent = crate::core::tokens::count_tokens(&result);
278 return Ok(ToolOutput {
279 text: result,
280 original_tokens: original,
281 saved_tokens: original.saturating_sub(sent),
282 mode: Some("handle".to_string()),
283 path: None,
284 changed: false,
285 shell_outcome: None,
286 });
287 }
288
289 let name = get_str(args, "name").ok_or_else(|| {
290 ErrorData::invalid_params("name or handle is required for action=symbol", None)
291 })?;
292 let file = get_str(args, "file");
293 let kind = get_str(args, "kind");
294
295 let (result, original) = crate::tools::ctx_symbol::handle(
296 &name,
297 file.as_deref(),
298 kind.as_deref(),
299 &ctx.project_root,
300 );
301 let sent = crate::core::tokens::count_tokens(&result);
302 Ok(ToolOutput {
303 text: result,
304 original_tokens: original,
305 saved_tokens: original.saturating_sub(sent),
306 mode: kind,
307 path: file,
308 changed: false,
309 shell_outcome: None,
310 })
311}
312
313fn handle_reindex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
315 let path = resolve_path_or_root(ctx)?;
316 let workspace = get_bool(args, "workspace").unwrap_or(false);
317 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
318 prime_bm25_cache(ctx);
319
320 let result = tokio::task::block_in_place(|| {
321 if artifacts {
322 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
323 } else {
324 crate::tools::ctx_semantic_search::handle_reindex(&path)
325 }
326 });
327 Ok(semantic_output(result))
328}
329
330fn handle_find_related(
332 args: &Map<String, Value>,
333 ctx: &ToolContext,
334) -> Result<ToolOutput, ErrorData> {
335 let path = resolve_path_or_root(ctx)?;
336 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
337 let fp = get_str(args, "file_path").unwrap_or_default();
338 let line = get_int(args, "line").unwrap_or(1) as usize;
339 if fp.is_empty() {
340 return Err(ErrorData::invalid_params(
341 "find_related requires file_path and line",
342 None,
343 ));
344 }
345 prime_bm25_cache(ctx);
346
347 let result = tokio::task::block_in_place(|| {
348 crate::tools::ctx_semantic_search::handle_find_related(
349 &fp,
350 line,
351 &path,
352 top_k,
353 ctx.crp_mode,
354 )
355 });
356 Ok(semantic_output(result))
357}
358
359fn semantic_output(text: String) -> ToolOutput {
362 ToolOutput {
363 text,
364 original_tokens: 0,
365 saved_tokens: 0,
366 mode: Some("semantic".to_string()),
367 path: None,
368 changed: false,
369 shell_outcome: None,
370 }
371}
372
373#[allow(clippy::too_many_arguments)]
374fn search_single(
375 pattern: &str,
376 path: &str,
377 include: Option<&str>,
378 max: usize,
379 crp: crate::tools::CrpMode,
380 respect_gitignore: bool,
381 allow_secret_paths: bool,
382 anchored: bool,
383) -> Result<ToolOutput, ErrorData> {
384 let _mode_guard = crate::core::savings_footer::ModeGuard::new("search");
385
386 let search_result = tokio::task::block_in_place(|| {
387 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
388 crate::tools::ctx_search::handle(
389 pattern,
390 path,
391 include,
392 max,
393 crp,
394 respect_gitignore,
395 allow_secret_paths,
396 anchored,
397 )
398 }));
399 match result {
400 Ok(r) => Ok(r),
401 Err(_) => Err("search task panicked"),
402 }
403 });
404
405 let outcome = match search_result {
406 Ok(r) => r,
407 Err(e) => {
408 return Err(ErrorData::internal_error(
409 format!("search task failed: {e}"),
410 None,
411 ));
412 }
413 };
414 let result = outcome.text;
415 let observed = outcome.observed_tokens;
418
419 if result.starts_with("ERROR:") {
420 return Err(ErrorData::invalid_params(result, None));
421 }
422
423 let sent = crate::core::tokens::count_tokens(&result);
424 let saved = observed.saturating_sub(sent);
425 let final_out = crate::core::protocol::append_savings(&result, observed, sent);
426 crate::core::savings_ledger::record_tool_event("ctx_search", observed, sent);
429
430 Ok(ToolOutput {
431 text: final_out,
432 original_tokens: observed,
433 saved_tokens: saved,
434 mode: None,
435 path: Some(path.to_string()),
436 changed: false,
437 shell_outcome: None,
438 })
439}
440
441fn ext_to_include(ext: &str) -> String {
449 if ext.contains(['*', '{', '?', '/']) {
450 return ext.to_string();
451 }
452 let bare = ext.strip_prefix('.').unwrap_or(ext);
453 format!("*.{bare}")
454}
455
456#[cfg(test)]
457mod tests {
458 use super::{SearchAction, ext_to_include};
459 use serde_json::{Map, Value, json};
460
461 fn args(pairs: &[(&str, Value)]) -> Map<String, Value> {
462 pairs
463 .iter()
464 .cloned()
465 .map(|(k, v)| (k.to_string(), v))
466 .collect()
467 }
468
469 #[test]
470 fn explicit_action_selects_engine() {
471 assert_eq!(
473 SearchAction::resolve(&args(&[("action", json!("semantic"))])),
474 SearchAction::Semantic
475 );
476 assert_eq!(
477 SearchAction::resolve(&args(&[("action", json!("symbol"))])),
478 SearchAction::Symbol
479 );
480 assert_eq!(
481 SearchAction::resolve(&args(&[("action", json!("grep"))])),
482 SearchAction::Regex
483 );
484 assert_eq!(
485 SearchAction::resolve(&args(&[("action", json!("related"))])),
486 SearchAction::FindRelated
487 );
488 assert_eq!(
489 SearchAction::resolve(&args(&[("action", json!("reindex"))])),
490 SearchAction::Reindex
491 );
492 }
493
494 #[test]
495 fn action_inferred_from_fields_for_backward_compat() {
496 assert_eq!(
498 SearchAction::resolve(&args(&[("pattern", json!("fn .*"))])),
499 SearchAction::Regex
500 );
501 assert_eq!(
502 SearchAction::resolve(&args(&[("query", json!("user auth"))])),
503 SearchAction::Semantic
504 );
505 assert_eq!(
506 SearchAction::resolve(&args(&[("name", json!("handle"))])),
507 SearchAction::Symbol
508 );
509 assert_eq!(
510 SearchAction::resolve(&args(&[("file_path", json!("a.rs")), ("line", json!(10))])),
511 SearchAction::FindRelated
512 );
513 }
514
515 #[test]
516 fn handle_infers_symbol_action() {
517 assert_eq!(
519 SearchAction::resolve(&args(&[("handle", json!("src/lib.rs#Config::load@L22"))])),
520 SearchAction::Symbol
521 );
522 }
523
524 #[test]
525 fn pattern_wins_over_query_and_unknown_action_falls_back_to_inference() {
526 assert_eq!(
528 SearchAction::resolve(&args(&[("pattern", json!("x")), ("query", json!("y"))])),
529 SearchAction::Regex
530 );
531 assert_eq!(
533 SearchAction::resolve(&args(&[("action", json!("bogus")), ("name", json!("f"))])),
534 SearchAction::Symbol
535 );
536 assert_eq!(SearchAction::resolve(&args(&[])), SearchAction::Regex);
538 }
539
540 #[test]
541 fn ext_alias_bare_extension_becomes_glob() {
542 assert_eq!(ext_to_include("rs"), "*.rs");
543 assert_eq!(ext_to_include("ts"), "*.ts");
544 }
545
546 #[test]
547 fn ext_alias_strips_leading_dot() {
548 assert_eq!(ext_to_include(".rs"), "*.rs");
549 assert_eq!(ext_to_include(".tsx"), "*.tsx");
550 }
551
552 #[test]
553 fn ext_alias_passes_through_glob_like_values() {
554 assert_eq!(ext_to_include("*.rs"), "*.rs");
556 assert_eq!(ext_to_include("*.{rs,ts}"), "*.{rs,ts}");
557 assert_eq!(ext_to_include("src/**/*.tsx"), "src/**/*.tsx");
558 }
559}