1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, get_str_array, get_usize,
8};
9use lean_ctx::core::ocla::cache_types::{CacheKeyBuilder, SearchQueryKey};
10use lean_ctx::tool_defs::tool_def;
11
12pub struct CtxSearchTool;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum SearchAction {
23 Regex,
24 Semantic,
25 Symbol,
26 MovedReindex,
28 FindRelated,
29}
30
31impl SearchAction {
32 fn resolve(args: &Map<String, Value>) -> Self {
36 if let Some(a) = get_str(args, "action") {
37 match a.trim().to_ascii_lowercase().as_str() {
38 "regex" | "grep" | "pattern" => return Self::Regex,
39 "semantic" | "search" => return Self::Semantic,
40 "symbol" => return Self::Symbol,
41 "reindex" => return Self::MovedReindex,
42 "find_related" | "related" => return Self::FindRelated,
43 _ => {}
44 }
45 }
46 if args.contains_key("handle") {
47 Self::Symbol
48 } else if args.contains_key("pattern") {
49 Self::Regex
50 } else if args.contains_key("name") {
51 Self::Symbol
52 } else if args.contains_key("file_path") && args.contains_key("line") {
53 Self::FindRelated
54 } else if args.contains_key("query") {
55 Self::Semantic
56 } else {
57 Self::Regex
58 }
59 }
60}
61
62pub(crate) fn uses_kernel_context(args: &Map<String, Value>) -> bool {
64 SearchAction::resolve(args) == SearchAction::Semantic
65}
66
67impl McpTool for CtxSearchTool {
68 fn name(&self) -> &'static str {
69 "ctx_search"
70 }
71
72 fn tool_def(&self) -> Tool {
73 tool_def(
74 "ctx_search",
75 "Search code: regex(pattern, default) | semantic(query) | symbol(name|handle) | \
76 find_related(file_path,line). Read-only. anchored=true enables ctx_patch refs; \
77 queries batches regex searches. Run ctx_compose FIRST.",
78 json!({
79 "type": "object",
80 "properties": {
81 "action": {
82 "type": "string",
83 "enum": ["regex", "semantic", "symbol", "find_related"]
84 },
85 "pattern": { "type": "string" },
86 "query": { "type": "string" },
87 "name": { "type": "string" },
88 "handle": { "type": "string" },
89 "path": { "type": "string" },
90 "paths": { "type": "array", "items": { "type": "string" } },
91 "include": { "type": "string", "description": "Glob, e.g. *.rs" },
92 "exclude": { "type": "string" },
93 "exclude_pattern": { "type": "string" },
94 "anchored": { "type": "boolean" },
95 "max_results": {
96 "type": "integer",
97 "description": "shared across queries"
98 },
99 "top_k": { "type": "integer" },
100 "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
101 "file": { "type": "string" },
102 "kind": { "type": "string" },
103 "file_path": { "type": "string" },
104 "line": { "type": "integer" },
105 "queries": {
106 "type": "array",
107 "items": { "type": "object" }
108 }
109 },
110 "allOf": [
111 {
112 "if": { "properties": { "action": { "const": "regex" } }, "required": ["action"] },
113 "then": { "anyOf": [{ "required": ["pattern"] }, { "required": ["queries"] }] }
114 },
115 {
116 "if": { "properties": { "action": { "const": "semantic" } }, "required": ["action"] },
117 "then": { "required": ["query"] }
118 },
119 {
120 "if": { "properties": { "action": { "const": "symbol" } }, "required": ["action"] },
121 "then": { "anyOf": [{ "required": ["name"] }, { "required": ["handle"] }] }
122 }
123 ]
124 }),
125 )
126 }
127
128 fn handle(
129 &self,
130 args: &Map<String, Value>,
131 ctx: &ToolContext,
132 ) -> Result<ToolOutput, ErrorData> {
133 let action = SearchAction::resolve(args);
134 let result = match action {
135 SearchAction::Regex => handle_regex(args, ctx),
136 SearchAction::Semantic => handle_semantic(args, ctx),
137 SearchAction::Symbol => handle_symbol(args, ctx),
138 SearchAction::MovedReindex => Err(moved_reindex_error()),
139 SearchAction::FindRelated => handle_find_related(args, ctx),
140 };
141 if let Ok(output) = &result {
142 record_attribution_result(ctx, action, args, output);
143 }
144 result
145 }
146
147 fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
158 args.is_some_and(|args| matches!(SearchAction::resolve(args), SearchAction::Regex))
159 }
160}
161
162fn record_attribution_result(
163 ctx: &ToolContext,
164 action: SearchAction,
165 args: &Map<String, Value>,
166 output: &ToolOutput,
167) {
168 let session_id = lean_ctx::core::task_spine::TaskSpine::task_id()
169 .or_else(|| {
170 ctx.session
171 .as_ref()
172 .and_then(|session| session.try_read().ok().map(|state| state.id.clone()))
173 })
174 .unwrap_or_else(|| "mcp-session".to_string());
175 let action = match action {
176 SearchAction::Regex => "regex",
177 SearchAction::Semantic => "semantic",
178 SearchAction::Symbol => "symbol",
179 SearchAction::MovedReindex => "reindex",
180 SearchAction::FindRelated => "find_related",
181 };
182 let query = get_str(args, "pattern")
183 .or_else(|| get_str(args, "query"))
184 .or_else(|| get_str(args, "handle"))
185 .or_else(|| get_str(args, "name"))
186 .or_else(|| get_str(args, "file_path"))
187 .unwrap_or_default();
188 let source = if query.is_empty() {
189 format!("ctx_search {action}")
190 } else {
191 format!("ctx_search {action} {query}")
192 };
193 let turn_provided = ctx.call_count.as_ref().map_or(0, |count| {
194 count.load(std::sync::atomic::Ordering::Relaxed) as u64
195 });
196 let token_cost = lean_ctx::core::tokens::count_tokens(&output.text);
197 let chunk = lean_ctx::core::causal_attribution::ContextChunkRecord::new(
198 &output.text,
199 source,
200 token_cost,
201 turn_provided,
202 );
203 if let Err(error) = lean_ctx::core::causal_attribution::record_chunk(&session_id, chunk) {
204 tracing::debug!(%error, "causal attribution ctx_search recording failed");
205 }
206}
207
208const KNOWN_KEYS: &[&str] = &[
211 "action",
212 "pattern",
213 "query",
214 "name",
215 "handle",
216 "path",
217 "paths",
218 "include",
219 "exclude",
220 "exclude_pattern",
221 "ext",
222 "anchored",
223 "max_results",
224 "top_k",
225 "mode",
226 "file",
227 "kind",
228 "file_path",
229 "line",
230 "languages",
231 "path_glob",
232 "workspace",
233 "artifacts",
234 "ignore_gitignore",
235];
236
237fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
239 if let Some(Value::Array(queries)) = args.get("queries") {
242 return handle_batch_queries(queries, args, ctx);
243 }
244 let pattern = get_str(args, "pattern")
248 .or_else(|| {
249 args.iter()
250 .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
251 .and_then(|(_, v)| v.as_str().map(String::from))
252 })
253 .ok_or_else(|| {
254 ErrorData::invalid_params(
255 "pattern is required. Example: ctx_search(pattern=\"fn main\", path=\"/src\")",
256 None,
257 )
258 })?;
259 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
260 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
261 let include =
264 get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
265 let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
266 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
267 let anchored = get_bool(args, "anchored").unwrap_or(false);
269 let exclude = get_str(args, "exclude");
272 let exclude_pattern = get_str(args, "exclude_pattern");
273
274 if no_gitignore
275 && let Err(e) = lean_ctx::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
276 {
277 return Err(crate::server::tool_trait::tool_execution_error(e));
279 }
280
281 let crp = ctx.crp_mode;
282 let respect = !no_gitignore;
283 let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
284
285 if !resolved.is_multi {
286 return search_single(
287 &pattern,
288 &resolved.roots[0],
289 include.as_deref(),
290 max,
291 crp,
292 respect,
293 allow_secret_paths,
294 anchored,
295 exclude.as_deref(),
296 exclude_pattern.as_deref(),
297 )
298 .map(|output| resolved.annotate(output));
299 }
300
301 let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
302 let per_root_max = (max / resolved.roots.len()).max(5);
303 let mut combined = String::new();
304 let mut total_observed: usize = 0;
305 let mut total_sent: usize = 0;
306 let mut remaining_budget = max;
307 let mut failed_roots: usize = 0;
310 let mut answered_roots: usize = 0;
311
312 for root in &resolved.roots {
313 if remaining_budget == 0 {
314 break;
315 }
316 let this_root_max = per_root_max.min(remaining_budget);
317 let search_result = tokio::task::block_in_place(|| {
318 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
319 cached_or_search(
320 &pattern,
321 root,
322 include.as_deref(),
323 this_root_max,
324 crp,
325 respect,
326 allow_secret_paths,
327 anchored,
328 exclude.as_deref(),
329 exclude_pattern.as_deref(),
330 )
331 }))
332 .ok()
333 });
334
335 let Some(outcome) = search_result else {
336 combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
337 failed_roots += 1;
338 continue;
339 };
340 note_secret_paths_withheld(outcome.secret_paths_withheld);
341 let failed = outcome.failed;
342 let result = outcome.text;
343
344 if result.trim().is_empty() {
345 continue;
346 }
347
348 combined.push_str(&format!("── {root} ──\n{result}\n\n"));
349
350 if failed {
351 failed_roots += 1;
352 continue;
353 }
354 answered_roots += 1;
355
356 let result_matches = result.lines().filter(|l| !l.is_empty()).count();
357 remaining_budget = remaining_budget.saturating_sub(result_matches);
358 total_observed += outcome.observed_tokens;
359 total_sent += lean_ctx::core::tokens::count_tokens(&result);
360 }
361
362 if combined.is_empty() {
363 combined = "No matches found across any root.".to_string();
364 }
365
366 let final_out = lean_ctx::core::protocol::append_savings(&combined, total_observed, total_sent);
371 if failed_roots > 0 && answered_roots == 0 {
372 return Err(crate::server::tool_trait::tool_execution_error(final_out));
374 }
375 let saved = total_observed.saturating_sub(total_sent);
376 lean_ctx::core::savings_ledger::record_tool_event(
380 "ctx_search",
381 total_observed,
382 total_sent,
383 None,
384 None,
385 );
386
387 crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
389
390 Ok(ToolOutput {
391 text: final_out,
392 original_tokens: total_observed,
393 saved_tokens: saved,
394 mode: None,
395 path: None,
396 changed: false,
397 shell_outcome: None,
398 content_blocks: None,
399 })
400}
401
402fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
405 if let Some(p) = ctx.resolved_path("path") {
406 Ok(p.to_string())
407 } else if let Some(err) = ctx.path_error("path") {
408 Err(ErrorData::invalid_params(format!("path: {err}"), None))
409 } else {
410 Ok(ctx.project_root.clone())
411 }
412}
413
414fn prime_bm25_cache(ctx: &ToolContext) {
417 if let Some(ref cache) = ctx.bm25_cache {
418 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
419 }
420}
421
422fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
424 let path = resolve_path_or_root(ctx)?;
425 lean_ctx::core::policy::runtime::with_project_source_view(&path, || {
426 handle_semantic_in_view(args, ctx)
427 })
428 .map_err(|_| {
429 ErrorData::internal_error("Search source authority changed or is unavailable", None)
430 })?
431}
432
433fn handle_semantic_in_view(
434 args: &Map<String, Value>,
435 ctx: &ToolContext,
436) -> Result<ToolOutput, ErrorData> {
437 let query = get_str(args, "query")
438 .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
439 let path = resolve_path_or_root(ctx)?;
440 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
441 let mode = get_str(args, "mode");
442 let languages = get_str_array(args, "languages");
443 let path_glob = get_str(args, "path_glob");
444 let workspace = get_bool(args, "workspace").unwrap_or(false);
445 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
446 prime_bm25_cache(ctx);
447
448 let search_phase = lean_ctx::core::phase_timing::enter("ctx_search semantic: search");
449 let mut result = tokio::task::block_in_place(|| {
450 crate::tools::ctx_semantic_search::handle(
451 &query,
452 &path,
453 top_k,
454 ctx.crp_mode,
455 languages.as_deref(),
456 path_glob.as_deref(),
457 mode.as_deref(),
458 Some(workspace),
459 Some(artifacts),
460 )
461 });
462
463 drop(search_phase);
464
465 if !lean_ctx::core::policy::runtime::is_active() && !result.starts_with("ERR:") {
468 let _phase = lean_ctx::core::phase_timing::enter("ctx_search semantic: kernel enrichment");
469 let kernel_budget = 100;
470 if let Some(enrichment) =
471 lean_ctx::core::context_kernel::bridge::kernel_enrich(&query, &path, kernel_budget)
472 && !enrichment.blocks.is_empty()
473 {
474 result.push_str("\n--- kernel context ---\n");
475 result.push_str(&enrichment.blocks);
476 }
477 }
478
479 let search_tokens = lean_ctx::core::tokens::count_tokens(&result);
481 crate::tools::search_hook::on_search(&query, "semantic", search_tokens, search_tokens);
482 Ok(semantic_output(result))
483}
484
485fn resolve_symbol_root(args: &Map<String, Value>, session_root: &str) -> String {
489 let candidate = get_str(args, "path")
490 .or_else(|| get_str(args, "file"))
491 .filter(|p| std::path::Path::new(p.as_str()).is_absolute());
492
493 if let Some(abs_path) = candidate
494 && let Some(detected) = lean_ctx::core::protocol::detect_project_root(&abs_path)
495 && detected != session_root
496 && lean_ctx::core::workspace_authority::root_authorized(std::path::Path::new(&detected))
498 {
499 return detected;
500 }
501 session_root.to_string()
502}
503
504fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
508 let effective_root = resolve_symbol_root(args, &ctx.project_root);
512
513 if let Some(handle) = get_str(args, "handle") {
514 let (result, original) =
515 crate::tools::ctx_symbol::render_by_handle(&handle, &effective_root);
516 let sent = lean_ctx::core::tokens::count_tokens(&result);
517 return Ok(ToolOutput {
518 text: result,
519 original_tokens: original,
520 saved_tokens: original.saturating_sub(sent),
521 mode: Some("handle".to_string()),
522 path: None,
523 changed: false,
524 shell_outcome: None,
525 content_blocks: None,
526 });
527 }
528
529 let name = ["name", "symbol", "query", "pattern"]
532 .iter()
533 .find_map(|key| get_str(args, key).filter(|value| !value.trim().is_empty()))
534 .ok_or_else(|| {
535 ErrorData::invalid_params("name or handle is required for action=symbol", None)
536 })?;
537 let file = get_str(args, "file");
538 let kind = get_str(args, "kind");
539
540 let (result, original) =
541 crate::tools::ctx_symbol::handle(&name, file.as_deref(), kind.as_deref(), &effective_root);
542 let sent = lean_ctx::core::tokens::count_tokens(&result);
543 crate::tools::search_hook::on_search(&name, "symbol", original, sent);
545 Ok(ToolOutput {
546 text: result,
547 original_tokens: original,
548 saved_tokens: original.saturating_sub(sent),
549 mode: kind,
550 path: file,
551 changed: false,
552 shell_outcome: None,
553 content_blocks: None,
554 })
555}
556
557fn moved_reindex_error() -> ErrorData {
569 ErrorData::invalid_params(
570 "ctx_search no longer performs reindex — it is read-only so it stays available \
571 in read-only/plan modes (#1624). Rebuild the index with \
572 ctx_index(action=\"build-full\"), or ctx_index(action=\"build\") for an \
573 incremental pass.",
574 None,
575 )
576}
577
578fn handle_find_related(
580 args: &Map<String, Value>,
581 ctx: &ToolContext,
582) -> Result<ToolOutput, ErrorData> {
583 let path = resolve_path_or_root(ctx)?;
584 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
585 let fp = get_str(args, "file_path").unwrap_or_default();
586 let line = get_int(args, "line").unwrap_or(1) as usize;
587 if fp.is_empty() {
588 return Err(ErrorData::invalid_params(
589 "find_related requires file_path and line",
590 None,
591 ));
592 }
593 prime_bm25_cache(ctx);
594
595 let result = tokio::task::block_in_place(|| {
596 crate::tools::ctx_semantic_search::handle_find_related(
597 &fp,
598 line,
599 &path,
600 top_k,
601 ctx.crp_mode,
602 )
603 });
604
605 Ok(semantic_output(result))
606}
607
608fn semantic_output(text: String) -> ToolOutput {
611 let tokens = lean_ctx::core::tokens::count_tokens(&text);
612 ToolOutput {
613 text,
614 original_tokens: tokens,
615 saved_tokens: 0,
616 mode: Some("semantic".to_string()),
617 path: None,
618 changed: false,
619 shell_outcome: None,
620 content_blocks: None,
621 }
622}
623
624fn note_secret_paths_withheld(n: usize) {
627 lean_ctx::core::security_events::note_refusals(
628 lean_ctx::core::security_events::RefusalClass::SecretPath,
629 n,
630 );
631}
632
633#[allow(clippy::too_many_arguments)]
634fn cached_or_search(
635 pattern: &str,
636 path: &str,
637 include: Option<&str>,
638 max: usize,
639 crp: crate::tools::CrpMode,
640 respect_gitignore: bool,
641 allow_secret_paths: bool,
642 anchored: bool,
643 exclude: Option<&str>,
644 exclude_pattern: Option<&str>,
645) -> crate::tools::ctx_search::SearchOutcome {
646 let builder = regex_cache_builder(
647 pattern,
648 path,
649 include,
650 exclude,
651 exclude_pattern,
652 max,
653 respect_gitignore,
654 allow_secret_paths,
655 anchored,
656 );
657 if let Some(builder) = &builder
658 && let Some(entry) = lean_ctx::core::ocla::cache_delivery::check(
659 &builder.cache_key(),
660 &builder.validator(),
661 "ctx_search",
662 )
663 {
664 let text = lean_ctx::core::ocla::cache_delivery::stub(&entry, "regex search");
665 return crate::tools::ctx_search::SearchOutcome {
666 text,
667 modeled_baseline: entry.token_count as usize,
668 observed_tokens: entry.token_count as usize,
669 failed: false,
670 secret_paths_withheld: 0,
672 };
673 }
674
675 let outcome = crate::tools::ctx_search::handle_filtered(
676 pattern,
677 path,
678 include,
679 max,
680 crp,
681 respect_gitignore,
682 allow_secret_paths,
683 anchored,
684 exclude,
685 exclude_pattern,
686 );
687 if !outcome.failed
688 && let Some(builder) = builder
689 {
690 lean_ctx::core::ocla::cache_delivery::record(
691 builder.cache_key(),
692 lean_ctx::core::ocla::cache_types::DeliveryKind::SearchQuery,
693 builder.validator(),
694 Some(builder.path),
695 &outcome.text,
696 "ctx_search",
697 );
698 }
699 outcome
700}
701
702#[allow(clippy::too_many_arguments)]
703fn regex_cache_builder(
704 pattern: &str,
705 path: &str,
706 include: Option<&str>,
707 exclude: Option<&str>,
708 exclude_pattern: Option<&str>,
709 max: usize,
710 respect_gitignore: bool,
711 allow_secret_paths: bool,
712 anchored: bool,
713) -> Option<SearchQueryKey> {
714 if lean_ctx::core::policy::runtime::active().is_some() {
715 return None;
716 }
717 let canonical = lean_ctx::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
718 let revision = lean_ctx::core::search_index::corpus_signature(
722 &canonical.to_string_lossy(),
723 respect_gitignore,
724 allow_secret_paths,
725 )?;
726 Some(SearchQueryKey {
727 pattern: pattern.into(),
728 include: format!(
729 "{}\\x1fmax:{max}\\x1fgitignore:{respect_gitignore}\\x1fsecret:{allow_secret_paths}\\x1fanchored:{anchored}",
730 include.unwrap_or_default()
731 ),
732 exclude: format!(
733 "{}\\x1fline:{}",
734 exclude.unwrap_or_default(),
735 exclude_pattern.unwrap_or_default()
736 ),
737 path: canonical.to_string_lossy().into_owned(),
738 index_rev: revision.to_string(),
739 })
740}
741
742#[cfg(test)]
743mod cache_delivery_tests {
744 use super::*;
745
746 #[test]
747 fn regex_adapter_records_then_serves_a_cross_agent_reference() {
748 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
749 let directory = tempfile::tempdir().unwrap();
750 std::fs::write(
751 directory.path().join("cached.rs"),
752 "fn cache_delivery_probe() {}\n",
753 )
754 .unwrap();
755 let path = directory.path().to_string_lossy();
756
757 let first = search_single(
758 "cache_delivery_probe",
759 &path,
760 Some("*.rs"),
761 20,
762 crate::tools::CrpMode::Off,
763 true,
764 true,
765 false,
766 None,
767 None,
768 )
769 .unwrap();
770 assert!(first.text.contains("cache_delivery_probe"));
771 let second = search_single(
772 "cache_delivery_probe",
773 &path,
774 Some("*.rs"),
775 20,
776 crate::tools::CrpMode::Off,
777 true,
778 true,
779 false,
780 None,
781 None,
782 )
783 .unwrap();
784 assert!(
785 second.text.contains("[cross-agent cache"),
786 "{}",
787 second.text
788 );
789 std::fs::write(
791 directory.path().join("cached.rs"),
792 "fn cache_delivery_probe_updated() {}\n",
793 )
794 .unwrap();
795 let updated = search_single(
796 "cache_delivery_probe",
797 &path,
798 Some("*.rs"),
799 20,
800 crate::tools::CrpMode::Off,
801 true,
802 true,
803 false,
804 None,
805 None,
806 )
807 .unwrap();
808 assert!(updated.text.contains("cache_delivery_probe_updated"));
809 assert!(!updated.text.contains("[cross-agent cache"));
810 }
811}
812
813#[allow(clippy::too_many_arguments)]
814fn search_single(
815 pattern: &str,
816 path: &str,
817 include: Option<&str>,
818 max: usize,
819 crp: crate::tools::CrpMode,
820 respect_gitignore: bool,
821 allow_secret_paths: bool,
822 anchored: bool,
823 exclude: Option<&str>,
824 exclude_pattern: Option<&str>,
825) -> Result<ToolOutput, ErrorData> {
826 let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
827
828 let search_result = tokio::task::block_in_place(|| {
829 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
830 cached_or_search(
831 pattern,
832 path,
833 include,
834 max,
835 crp,
836 respect_gitignore,
837 allow_secret_paths,
838 anchored,
839 exclude,
840 exclude_pattern,
841 )
842 }));
843 match result {
844 Ok(r) => Ok(r),
845 Err(_) => Err("search task panicked"),
846 }
847 });
848
849 let outcome = match search_result {
850 Ok(r) => r,
851 Err(e) => {
852 return Err(ErrorData::internal_error(
853 format!("search task failed: {e}"),
854 None,
855 ));
856 }
857 };
858 note_secret_paths_withheld(outcome.secret_paths_withheld);
859 let failed = outcome.failed;
860 let result = outcome.text;
861 let observed = outcome.observed_tokens;
864
865 if failed {
868 return Err(ErrorData::invalid_params(result, None));
869 }
870
871 let sent = lean_ctx::core::tokens::count_tokens(&result);
872 let saved = observed.saturating_sub(sent);
873 let final_out = lean_ctx::core::protocol::append_savings(&result, observed, sent);
874 lean_ctx::core::savings_ledger::record_tool_event("ctx_search", observed, sent, None, None);
877
878 crate::tools::search_hook::on_search(pattern, "regex", observed, sent);
880
881 Ok(ToolOutput {
882 text: final_out,
883 original_tokens: observed,
884 saved_tokens: saved,
885 mode: None,
886 path: Some(path.to_string()),
887 changed: false,
888 shell_outcome: None,
889 content_blocks: None,
890 })
891}
892
893const QUERY_KEYS: &[&str] = &[
906 "pattern",
907 "include",
908 "ext",
909 "exclude",
910 "exclude_pattern",
911 "max_results",
912 "path",
913 "file_path",
914];
915
916const QUERY_KEY_ALIASES: &[(&str, &str)] = &[
921 ("regex", "pattern"),
922 ("query", "pattern"),
923 ("maxResults", "max_results"),
924 ("limit", "max_results"),
925 ("glob", "include"),
926 ("filePattern", "include"),
927 ("file_pattern", "include"),
928 ("excludePattern", "exclude_pattern"),
929 ("filePath", "file_path"),
930];
931
932fn normalize_query_keys(
935 obj: &Map<String, Value>,
936 idx: usize,
937) -> Result<Map<String, Value>, String> {
938 let mut out = Map::with_capacity(obj.len());
939 for (key, value) in obj {
940 let canonical = if QUERY_KEYS.contains(&key.as_str()) {
941 key.as_str()
942 } else if let Some((_, canonical)) =
943 QUERY_KEY_ALIASES.iter().find(|(alias, _)| *alias == key)
944 {
945 if obj.contains_key(*canonical) {
946 continue;
947 }
948 canonical
949 } else {
950 return Err(format!(
951 "queries[{}]: unknown key '{key}' — accepted keys are {}",
952 idx + 1,
953 QUERY_KEYS.join(", ")
954 ));
955 };
956 out.insert(canonical.to_string(), value.clone());
957 }
958 Ok(out)
959}
960
961fn resolve_query_cap(
965 obj: &Map<String, Value>,
966 default_share: usize,
967 idx: usize,
968) -> Result<usize, String> {
969 match get_int(obj, "max_results") {
970 Some(n) if n > 0 => Ok((n as usize).min(500)),
971 Some(_) => Err(format!(
972 "queries[{}].max_results must be a positive integer",
973 idx + 1
974 )),
975 None => Ok(default_share),
976 }
977}
978
979fn handle_batch_queries(
981 queries: &[Value],
982 args: &Map<String, Value>,
983 ctx: &ToolContext,
984) -> Result<ToolOutput, ErrorData> {
985 if queries.is_empty() {
986 return Err(ErrorData::invalid_params(
987 "queries array must not be empty",
988 None,
989 ));
990 }
991 if queries.len() > 10 {
992 return Err(ErrorData::invalid_params(
993 "queries array limited to 10 entries",
994 None,
995 ));
996 }
997
998 let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
999 .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
1000 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
1001 let anchored = get_bool(args, "anchored").unwrap_or(false);
1002 let crp = ctx.crp_mode;
1003 let respect = !no_gitignore;
1004 let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
1005 let root = &resolved.roots[0];
1006 let global_max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
1007 let default_share = (global_max / queries.len()).max(5);
1015
1016 let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
1017 let mut combined = String::new();
1018 let mut total_observed: usize = 0;
1019 let mut total_sent: usize = 0;
1020 let mut failed_queries: usize = 0;
1023 let mut answered_queries: usize = 0;
1024 let mut secret_paths_withheld: usize = 0;
1027
1028 for (idx, q) in queries.iter().enumerate() {
1029 let Some(obj) = q.as_object() else {
1030 combined.push_str(&format!(
1031 "── query {} ──\nERROR: expected object\n\n",
1032 idx + 1
1033 ));
1034 failed_queries += 1;
1035 continue;
1036 };
1037 let obj =
1041 &normalize_query_keys(obj, idx).map_err(|e| ErrorData::invalid_params(e, None))?;
1042 let Some(pattern) = get_str(obj, "pattern") else {
1043 combined.push_str(&format!(
1044 "── query {} ──\nERROR: pattern required\n\n",
1045 idx + 1
1046 ));
1047 failed_queries += 1;
1048 continue;
1049 };
1050 let include =
1051 get_str(obj, "include").or_else(|| get_str(obj, "ext").map(|e| ext_to_include(&e)));
1052 let exclude = get_str(obj, "exclude");
1053 let exclude_pattern = get_str(obj, "exclude_pattern");
1054 let per_query_max = resolve_query_cap(obj, default_share, idx)
1055 .map_err(|e| ErrorData::invalid_params(e, None))?;
1056 let query_root = match get_str(obj, "path").or_else(|| get_str(obj, "file_path")) {
1059 Some(raw) => match ctx.resolve_path_sync(&raw) {
1060 Ok(p) => p,
1061 Err(e) => {
1062 combined.push_str(&format!(
1063 "── query {}: '{}' ──\nERROR: {e}\n\n",
1064 idx + 1,
1065 truncate_query(&pattern, 40)
1066 ));
1067 continue;
1068 }
1069 },
1070 None => root.clone(),
1071 };
1072
1073 let search_result = tokio::task::block_in_place(|| {
1074 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1075 cached_or_search(
1076 &pattern,
1077 &query_root,
1078 include.as_deref(),
1079 per_query_max,
1080 crp,
1081 respect,
1082 allow_secret_paths,
1083 anchored,
1084 exclude.as_deref(),
1085 exclude_pattern.as_deref(),
1086 )
1087 }))
1088 .ok()
1089 });
1090
1091 let label = if queries.len() > 1 {
1092 format!(
1093 "── query {}: '{}' ──\n",
1094 idx + 1,
1095 truncate_query(&pattern, 40)
1096 )
1097 } else {
1098 String::new()
1099 };
1100
1101 let Some(outcome) = search_result else {
1102 combined.push_str(&format!("{label}ERROR: search panicked\n\n"));
1103 failed_queries += 1;
1104 continue;
1105 };
1106
1107 secret_paths_withheld = secret_paths_withheld.max(outcome.secret_paths_withheld);
1108 if outcome.failed {
1109 failed_queries += 1;
1110 } else {
1111 answered_queries += 1;
1112 }
1113 if !outcome.text.trim().is_empty() {
1114 combined.push_str(&format!("{label}{}\n\n", outcome.text));
1115 total_observed += outcome.observed_tokens;
1116 total_sent += lean_ctx::core::tokens::count_tokens(&outcome.text);
1117 }
1118 }
1119
1120 note_secret_paths_withheld(secret_paths_withheld);
1121 if combined.is_empty() {
1122 combined = "No matches found for any query.".to_string();
1123 }
1124
1125 let final_out = lean_ctx::core::protocol::append_savings(&combined, total_observed, total_sent);
1126 if failed_queries > 0 && answered_queries == 0 {
1127 return Err(crate::server::tool_trait::tool_execution_error(final_out));
1129 }
1130 let saved = total_observed.saturating_sub(total_sent);
1131 lean_ctx::core::savings_ledger::record_tool_event(
1132 "ctx_search",
1133 total_observed,
1134 total_sent,
1135 None,
1136 None,
1137 );
1138
1139 crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
1141
1142 Ok(resolved.annotate(ToolOutput {
1143 text: final_out,
1144 original_tokens: total_observed,
1145 saved_tokens: saved,
1146 mode: None,
1147 path: None,
1148 changed: false,
1149 shell_outcome: None,
1150 content_blocks: None,
1151 }))
1152}
1153
1154fn truncate_query(q: &str, max: usize) -> String {
1156 if q.len() <= max {
1157 q.to_string()
1158 } else {
1159 format!("{}...", &q[..q.floor_char_boundary(max)])
1160 }
1161}
1162
1163fn ext_to_include(ext: &str) -> String {
1164 if ext.contains(['*', '{', '?', '/']) {
1165 return ext.to_string();
1166 }
1167 let bare = ext.strip_prefix('.').unwrap_or(ext);
1168 format!("*.{bare}")
1169}
1170
1171#[cfg(test)]
1172#[path = "ctx_search_tests.rs"]
1173mod tests;
1174
1175#[cfg(test)]
1176#[path = "ctx_search_mcp_tests.rs"]
1177mod mcp_tests;