use rmcp::ErrorData;
use rmcp::model::Tool;
use serde_json::{Map, Value, json};
use crate::server::tool_trait::{
McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, get_str_array, get_usize,
};
use lean_ctx::core::ocla::cache_types::{CacheKeyBuilder, SearchQueryKey};
use lean_ctx::tool_defs::tool_def;
pub struct CtxSearchTool;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchAction {
Regex,
Semantic,
Symbol,
MovedReindex,
FindRelated,
}
impl SearchAction {
fn resolve(args: &Map<String, Value>) -> Self {
if let Some(a) = get_str(args, "action") {
match a.trim().to_ascii_lowercase().as_str() {
"regex" | "grep" | "pattern" => return Self::Regex,
"semantic" | "search" => return Self::Semantic,
"symbol" => return Self::Symbol,
"reindex" => return Self::MovedReindex,
"find_related" | "related" => return Self::FindRelated,
_ => {}
}
}
if args.contains_key("handle") {
Self::Symbol
} else if args.contains_key("pattern") {
Self::Regex
} else if args.contains_key("name") {
Self::Symbol
} else if args.contains_key("file_path") && args.contains_key("line") {
Self::FindRelated
} else if args.contains_key("query") {
Self::Semantic
} else {
Self::Regex
}
}
}
pub(crate) fn uses_kernel_context(args: &Map<String, Value>) -> bool {
SearchAction::resolve(args) == SearchAction::Semantic
}
impl McpTool for CtxSearchTool {
fn name(&self) -> &'static str {
"ctx_search"
}
fn tool_def(&self) -> Tool {
tool_def(
"ctx_search",
"Search code: regex(pattern, default) | semantic(query) | symbol(name|handle) | \
find_related(file_path,line). Read-only. anchored=true enables ctx_patch refs; \
queries batches regex searches. Run ctx_compose FIRST.",
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["regex", "semantic", "symbol", "find_related"]
},
"pattern": { "type": "string" },
"query": { "type": "string" },
"name": { "type": "string" },
"handle": { "type": "string" },
"path": { "type": "string" },
"paths": { "type": "array", "items": { "type": "string" } },
"include": { "type": "string", "description": "Glob, e.g. *.rs" },
"exclude": { "type": "string" },
"exclude_pattern": { "type": "string" },
"anchored": { "type": "boolean" },
"max_results": {
"type": "integer",
"description": "shared across queries"
},
"top_k": { "type": "integer" },
"mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
"file": { "type": "string" },
"kind": { "type": "string" },
"file_path": { "type": "string" },
"line": { "type": "integer" },
"queries": {
"type": "array",
"items": { "type": "object" }
}
},
"allOf": [
{
"if": { "properties": { "action": { "const": "regex" } }, "required": ["action"] },
"then": { "anyOf": [{ "required": ["pattern"] }, { "required": ["queries"] }] }
},
{
"if": { "properties": { "action": { "const": "semantic" } }, "required": ["action"] },
"then": { "required": ["query"] }
},
{
"if": { "properties": { "action": { "const": "symbol" } }, "required": ["action"] },
"then": { "anyOf": [{ "required": ["name"] }, { "required": ["handle"] }] }
}
]
}),
)
}
fn handle(
&self,
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
let action = SearchAction::resolve(args);
let result = match action {
SearchAction::Regex => handle_regex(args, ctx),
SearchAction::Semantic => handle_semantic(args, ctx),
SearchAction::Symbol => handle_symbol(args, ctx),
SearchAction::MovedReindex => Err(moved_reindex_error()),
SearchAction::FindRelated => handle_find_related(args, ctx),
};
if let Ok(output) = &result {
record_attribution_result(ctx, action, args, output);
}
result
}
fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
args.is_some_and(|args| matches!(SearchAction::resolve(args), SearchAction::Regex))
}
}
fn record_attribution_result(
ctx: &ToolContext,
action: SearchAction,
args: &Map<String, Value>,
output: &ToolOutput,
) {
let session_id = lean_ctx::core::task_spine::TaskSpine::task_id()
.or_else(|| {
ctx.session
.as_ref()
.and_then(|session| session.try_read().ok().map(|state| state.id.clone()))
})
.unwrap_or_else(|| "mcp-session".to_string());
let action = match action {
SearchAction::Regex => "regex",
SearchAction::Semantic => "semantic",
SearchAction::Symbol => "symbol",
SearchAction::MovedReindex => "reindex",
SearchAction::FindRelated => "find_related",
};
let query = get_str(args, "pattern")
.or_else(|| get_str(args, "query"))
.or_else(|| get_str(args, "handle"))
.or_else(|| get_str(args, "name"))
.or_else(|| get_str(args, "file_path"))
.unwrap_or_default();
let source = if query.is_empty() {
format!("ctx_search {action}")
} else {
format!("ctx_search {action} {query}")
};
let turn_provided = ctx.call_count.as_ref().map_or(0, |count| {
count.load(std::sync::atomic::Ordering::Relaxed) as u64
});
let token_cost = lean_ctx::core::tokens::count_tokens(&output.text);
let chunk = lean_ctx::core::causal_attribution::ContextChunkRecord::new(
&output.text,
source,
token_cost,
turn_provided,
);
if let Err(error) = lean_ctx::core::causal_attribution::record_chunk(&session_id, chunk) {
tracing::debug!(%error, "causal attribution ctx_search recording failed");
}
}
const KNOWN_KEYS: &[&str] = &[
"action",
"pattern",
"query",
"name",
"handle",
"path",
"paths",
"include",
"exclude",
"exclude_pattern",
"ext",
"anchored",
"max_results",
"top_k",
"mode",
"file",
"kind",
"file_path",
"line",
"languages",
"path_glob",
"workspace",
"artifacts",
"ignore_gitignore",
];
fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
if let Some(Value::Array(queries)) = args.get("queries") {
return handle_batch_queries(queries, args, ctx);
}
let pattern = get_str(args, "pattern")
.or_else(|| {
args.iter()
.find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
.and_then(|(_, v)| v.as_str().map(String::from))
})
.ok_or_else(|| {
ErrorData::invalid_params(
"pattern is required. Example: ctx_search(pattern=\"fn main\", path=\"/src\")",
None,
)
})?;
let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
.map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
let include =
get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
let anchored = get_bool(args, "anchored").unwrap_or(false);
let exclude = get_str(args, "exclude");
let exclude_pattern = get_str(args, "exclude_pattern");
if no_gitignore
&& let Err(e) = lean_ctx::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
{
return Err(crate::server::tool_trait::tool_execution_error(e));
}
let crp = ctx.crp_mode;
let respect = !no_gitignore;
let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
if !resolved.is_multi {
return search_single(
&pattern,
&resolved.roots[0],
include.as_deref(),
max,
crp,
respect,
allow_secret_paths,
anchored,
exclude.as_deref(),
exclude_pattern.as_deref(),
)
.map(|output| resolved.annotate(output));
}
let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
let per_root_max = (max / resolved.roots.len()).max(5);
let mut combined = String::new();
let mut total_observed: usize = 0;
let mut total_sent: usize = 0;
let mut remaining_budget = max;
let mut failed_roots: usize = 0;
let mut answered_roots: usize = 0;
for root in &resolved.roots {
if remaining_budget == 0 {
break;
}
let this_root_max = per_root_max.min(remaining_budget);
let search_result = tokio::task::block_in_place(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cached_or_search(
&pattern,
root,
include.as_deref(),
this_root_max,
crp,
respect,
allow_secret_paths,
anchored,
exclude.as_deref(),
exclude_pattern.as_deref(),
)
}))
.ok()
});
let Some(outcome) = search_result else {
combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
failed_roots += 1;
continue;
};
note_secret_paths_withheld(outcome.secret_paths_withheld);
let failed = outcome.failed;
let result = outcome.text;
if result.trim().is_empty() {
continue;
}
combined.push_str(&format!("── {root} ──\n{result}\n\n"));
if failed {
failed_roots += 1;
continue;
}
answered_roots += 1;
let result_matches = result.lines().filter(|l| !l.is_empty()).count();
remaining_budget = remaining_budget.saturating_sub(result_matches);
total_observed += outcome.observed_tokens;
total_sent += lean_ctx::core::tokens::count_tokens(&result);
}
if combined.is_empty() {
combined = "No matches found across any root.".to_string();
}
let final_out = lean_ctx::core::protocol::append_savings(&combined, total_observed, total_sent);
if failed_roots > 0 && answered_roots == 0 {
return Err(crate::server::tool_trait::tool_execution_error(final_out));
}
let saved = total_observed.saturating_sub(total_sent);
lean_ctx::core::savings_ledger::record_tool_event(
"ctx_search",
total_observed,
total_sent,
None,
None,
);
crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
Ok(ToolOutput {
text: final_out,
original_tokens: total_observed,
saved_tokens: saved,
mode: None,
path: None,
changed: false,
shell_outcome: None,
content_blocks: None,
})
}
fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
if let Some(p) = ctx.resolved_path("path") {
Ok(p.to_string())
} else if let Some(err) = ctx.path_error("path") {
Err(ErrorData::invalid_params(format!("path: {err}"), None))
} else {
Ok(ctx.project_root.clone())
}
}
fn prime_bm25_cache(ctx: &ToolContext) {
if let Some(ref cache) = ctx.bm25_cache {
crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
}
}
fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
let path = resolve_path_or_root(ctx)?;
lean_ctx::core::policy::runtime::with_project_source_view(&path, || {
handle_semantic_in_view(args, ctx)
})
.map_err(|_| {
ErrorData::internal_error("Search source authority changed or is unavailable", None)
})?
}
fn handle_semantic_in_view(
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
let query = get_str(args, "query")
.ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
let path = resolve_path_or_root(ctx)?;
let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
let mode = get_str(args, "mode");
let languages = get_str_array(args, "languages");
let path_glob = get_str(args, "path_glob");
let workspace = get_bool(args, "workspace").unwrap_or(false);
let artifacts = get_bool(args, "artifacts").unwrap_or(false);
prime_bm25_cache(ctx);
let search_phase = lean_ctx::core::phase_timing::enter("ctx_search semantic: search");
let mut result = tokio::task::block_in_place(|| {
crate::tools::ctx_semantic_search::handle(
&query,
&path,
top_k,
ctx.crp_mode,
languages.as_deref(),
path_glob.as_deref(),
mode.as_deref(),
Some(workspace),
Some(artifacts),
)
});
drop(search_phase);
if !lean_ctx::core::policy::runtime::is_active() && !result.starts_with("ERR:") {
let _phase = lean_ctx::core::phase_timing::enter("ctx_search semantic: kernel enrichment");
let kernel_budget = 100;
if let Some(enrichment) =
lean_ctx::core::context_kernel::bridge::kernel_enrich(&query, &path, kernel_budget)
&& !enrichment.blocks.is_empty()
{
result.push_str("\n--- kernel context ---\n");
result.push_str(&enrichment.blocks);
}
}
let search_tokens = lean_ctx::core::tokens::count_tokens(&result);
crate::tools::search_hook::on_search(&query, "semantic", search_tokens, search_tokens);
Ok(semantic_output(result))
}
fn resolve_symbol_root(args: &Map<String, Value>, session_root: &str) -> String {
let candidate = get_str(args, "path")
.or_else(|| get_str(args, "file"))
.filter(|p| std::path::Path::new(p.as_str()).is_absolute());
if let Some(abs_path) = candidate
&& let Some(detected) = lean_ctx::core::protocol::detect_project_root(&abs_path)
&& detected != session_root
&& lean_ctx::core::workspace_authority::root_authorized(std::path::Path::new(&detected))
{
return detected;
}
session_root.to_string()
}
fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
let effective_root = resolve_symbol_root(args, &ctx.project_root);
if let Some(handle) = get_str(args, "handle") {
let (result, original) =
crate::tools::ctx_symbol::render_by_handle(&handle, &effective_root);
let sent = lean_ctx::core::tokens::count_tokens(&result);
return Ok(ToolOutput {
text: result,
original_tokens: original,
saved_tokens: original.saturating_sub(sent),
mode: Some("handle".to_string()),
path: None,
changed: false,
shell_outcome: None,
content_blocks: None,
});
}
let name = ["name", "symbol", "query", "pattern"]
.iter()
.find_map(|key| get_str(args, key).filter(|value| !value.trim().is_empty()))
.ok_or_else(|| {
ErrorData::invalid_params("name or handle is required for action=symbol", None)
})?;
let file = get_str(args, "file");
let kind = get_str(args, "kind");
let (result, original) =
crate::tools::ctx_symbol::handle(&name, file.as_deref(), kind.as_deref(), &effective_root);
let sent = lean_ctx::core::tokens::count_tokens(&result);
crate::tools::search_hook::on_search(&name, "symbol", original, sent);
Ok(ToolOutput {
text: result,
original_tokens: original,
saved_tokens: original.saturating_sub(sent),
mode: kind,
path: file,
changed: false,
shell_outcome: None,
content_blocks: None,
})
}
fn moved_reindex_error() -> ErrorData {
ErrorData::invalid_params(
"ctx_search no longer performs reindex — it is read-only so it stays available \
in read-only/plan modes (#1624). Rebuild the index with \
ctx_index(action=\"build-full\"), or ctx_index(action=\"build\") for an \
incremental pass.",
None,
)
}
fn handle_find_related(
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
let path = resolve_path_or_root(ctx)?;
let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
let fp = get_str(args, "file_path").unwrap_or_default();
let line = get_int(args, "line").unwrap_or(1) as usize;
if fp.is_empty() {
return Err(ErrorData::invalid_params(
"find_related requires file_path and line",
None,
));
}
prime_bm25_cache(ctx);
let result = tokio::task::block_in_place(|| {
crate::tools::ctx_semantic_search::handle_find_related(
&fp,
line,
&path,
top_k,
ctx.crp_mode,
)
});
Ok(semantic_output(result))
}
fn semantic_output(text: String) -> ToolOutput {
let tokens = lean_ctx::core::tokens::count_tokens(&text);
ToolOutput {
text,
original_tokens: tokens,
saved_tokens: 0,
mode: Some("semantic".to_string()),
path: None,
changed: false,
shell_outcome: None,
content_blocks: None,
}
}
fn note_secret_paths_withheld(n: usize) {
lean_ctx::core::security_events::note_refusals(
lean_ctx::core::security_events::RefusalClass::SecretPath,
n,
);
}
#[allow(clippy::too_many_arguments)]
fn cached_or_search(
pattern: &str,
path: &str,
include: Option<&str>,
max: usize,
crp: crate::tools::CrpMode,
respect_gitignore: bool,
allow_secret_paths: bool,
anchored: bool,
exclude: Option<&str>,
exclude_pattern: Option<&str>,
) -> crate::tools::ctx_search::SearchOutcome {
let builder = regex_cache_builder(
pattern,
path,
include,
exclude,
exclude_pattern,
max,
respect_gitignore,
allow_secret_paths,
anchored,
);
if let Some(builder) = &builder
&& let Some(entry) = lean_ctx::core::ocla::cache_delivery::check(
&builder.cache_key(),
&builder.validator(),
"ctx_search",
)
{
let text = lean_ctx::core::ocla::cache_delivery::stub(&entry, "regex search");
return crate::tools::ctx_search::SearchOutcome {
text,
modeled_baseline: entry.token_count as usize,
observed_tokens: entry.token_count as usize,
failed: false,
secret_paths_withheld: 0,
};
}
let outcome = crate::tools::ctx_search::handle_filtered(
pattern,
path,
include,
max,
crp,
respect_gitignore,
allow_secret_paths,
anchored,
exclude,
exclude_pattern,
);
if !outcome.failed
&& let Some(builder) = builder
{
lean_ctx::core::ocla::cache_delivery::record(
builder.cache_key(),
lean_ctx::core::ocla::cache_types::DeliveryKind::SearchQuery,
builder.validator(),
Some(builder.path),
&outcome.text,
"ctx_search",
);
}
outcome
}
#[allow(clippy::too_many_arguments)]
fn regex_cache_builder(
pattern: &str,
path: &str,
include: Option<&str>,
exclude: Option<&str>,
exclude_pattern: Option<&str>,
max: usize,
respect_gitignore: bool,
allow_secret_paths: bool,
anchored: bool,
) -> Option<SearchQueryKey> {
if lean_ctx::core::policy::runtime::active().is_some() {
return None;
}
let canonical = lean_ctx::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
let revision = lean_ctx::core::search_index::corpus_signature(
&canonical.to_string_lossy(),
respect_gitignore,
allow_secret_paths,
)?;
Some(SearchQueryKey {
pattern: pattern.into(),
include: format!(
"{}\\x1fmax:{max}\\x1fgitignore:{respect_gitignore}\\x1fsecret:{allow_secret_paths}\\x1fanchored:{anchored}",
include.unwrap_or_default()
),
exclude: format!(
"{}\\x1fline:{}",
exclude.unwrap_or_default(),
exclude_pattern.unwrap_or_default()
),
path: canonical.to_string_lossy().into_owned(),
index_rev: revision.to_string(),
})
}
#[cfg(test)]
mod cache_delivery_tests {
use super::*;
#[test]
fn regex_adapter_records_then_serves_a_cross_agent_reference() {
let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
let directory = tempfile::tempdir().unwrap();
std::fs::write(
directory.path().join("cached.rs"),
"fn cache_delivery_probe() {}\n",
)
.unwrap();
let path = directory.path().to_string_lossy();
let first = search_single(
"cache_delivery_probe",
&path,
Some("*.rs"),
20,
crate::tools::CrpMode::Off,
true,
true,
false,
None,
None,
)
.unwrap();
assert!(first.text.contains("cache_delivery_probe"));
let second = search_single(
"cache_delivery_probe",
&path,
Some("*.rs"),
20,
crate::tools::CrpMode::Off,
true,
true,
false,
None,
None,
)
.unwrap();
assert!(
second.text.contains("[cross-agent cache"),
"{}",
second.text
);
std::fs::write(
directory.path().join("cached.rs"),
"fn cache_delivery_probe_updated() {}\n",
)
.unwrap();
let updated = search_single(
"cache_delivery_probe",
&path,
Some("*.rs"),
20,
crate::tools::CrpMode::Off,
true,
true,
false,
None,
None,
)
.unwrap();
assert!(updated.text.contains("cache_delivery_probe_updated"));
assert!(!updated.text.contains("[cross-agent cache"));
}
}
#[allow(clippy::too_many_arguments)]
fn search_single(
pattern: &str,
path: &str,
include: Option<&str>,
max: usize,
crp: crate::tools::CrpMode,
respect_gitignore: bool,
allow_secret_paths: bool,
anchored: bool,
exclude: Option<&str>,
exclude_pattern: Option<&str>,
) -> Result<ToolOutput, ErrorData> {
let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
let search_result = tokio::task::block_in_place(|| {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cached_or_search(
pattern,
path,
include,
max,
crp,
respect_gitignore,
allow_secret_paths,
anchored,
exclude,
exclude_pattern,
)
}));
match result {
Ok(r) => Ok(r),
Err(_) => Err("search task panicked"),
}
});
let outcome = match search_result {
Ok(r) => r,
Err(e) => {
return Err(ErrorData::internal_error(
format!("search task failed: {e}"),
None,
));
}
};
note_secret_paths_withheld(outcome.secret_paths_withheld);
let failed = outcome.failed;
let result = outcome.text;
let observed = outcome.observed_tokens;
if failed {
return Err(ErrorData::invalid_params(result, None));
}
let sent = lean_ctx::core::tokens::count_tokens(&result);
let saved = observed.saturating_sub(sent);
let final_out = lean_ctx::core::protocol::append_savings(&result, observed, sent);
lean_ctx::core::savings_ledger::record_tool_event("ctx_search", observed, sent, None, None);
crate::tools::search_hook::on_search(pattern, "regex", observed, sent);
Ok(ToolOutput {
text: final_out,
original_tokens: observed,
saved_tokens: saved,
mode: None,
path: Some(path.to_string()),
changed: false,
shell_outcome: None,
content_blocks: None,
})
}
const QUERY_KEYS: &[&str] = &[
"pattern",
"include",
"ext",
"exclude",
"exclude_pattern",
"max_results",
"path",
"file_path",
];
const QUERY_KEY_ALIASES: &[(&str, &str)] = &[
("regex", "pattern"),
("query", "pattern"),
("maxResults", "max_results"),
("limit", "max_results"),
("glob", "include"),
("filePattern", "include"),
("file_pattern", "include"),
("excludePattern", "exclude_pattern"),
("filePath", "file_path"),
];
fn normalize_query_keys(
obj: &Map<String, Value>,
idx: usize,
) -> Result<Map<String, Value>, String> {
let mut out = Map::with_capacity(obj.len());
for (key, value) in obj {
let canonical = if QUERY_KEYS.contains(&key.as_str()) {
key.as_str()
} else if let Some((_, canonical)) =
QUERY_KEY_ALIASES.iter().find(|(alias, _)| *alias == key)
{
if obj.contains_key(*canonical) {
continue;
}
canonical
} else {
return Err(format!(
"queries[{}]: unknown key '{key}' — accepted keys are {}",
idx + 1,
QUERY_KEYS.join(", ")
));
};
out.insert(canonical.to_string(), value.clone());
}
Ok(out)
}
fn resolve_query_cap(
obj: &Map<String, Value>,
default_share: usize,
idx: usize,
) -> Result<usize, String> {
match get_int(obj, "max_results") {
Some(n) if n > 0 => Ok((n as usize).min(500)),
Some(_) => Err(format!(
"queries[{}].max_results must be a positive integer",
idx + 1
)),
None => Ok(default_share),
}
}
fn handle_batch_queries(
queries: &[Value],
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
if queries.is_empty() {
return Err(ErrorData::invalid_params(
"queries array must not be empty",
None,
));
}
if queries.len() > 10 {
return Err(ErrorData::invalid_params(
"queries array limited to 10 entries",
None,
));
}
let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
.map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
let anchored = get_bool(args, "anchored").unwrap_or(false);
let crp = ctx.crp_mode;
let respect = !no_gitignore;
let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
let root = &resolved.roots[0];
let global_max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
let default_share = (global_max / queries.len()).max(5);
let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
let mut combined = String::new();
let mut total_observed: usize = 0;
let mut total_sent: usize = 0;
let mut failed_queries: usize = 0;
let mut answered_queries: usize = 0;
let mut secret_paths_withheld: usize = 0;
for (idx, q) in queries.iter().enumerate() {
let Some(obj) = q.as_object() else {
combined.push_str(&format!(
"── query {} ──\nERROR: expected object\n\n",
idx + 1
));
failed_queries += 1;
continue;
};
let obj =
&normalize_query_keys(obj, idx).map_err(|e| ErrorData::invalid_params(e, None))?;
let Some(pattern) = get_str(obj, "pattern") else {
combined.push_str(&format!(
"── query {} ──\nERROR: pattern required\n\n",
idx + 1
));
failed_queries += 1;
continue;
};
let include =
get_str(obj, "include").or_else(|| get_str(obj, "ext").map(|e| ext_to_include(&e)));
let exclude = get_str(obj, "exclude");
let exclude_pattern = get_str(obj, "exclude_pattern");
let per_query_max = resolve_query_cap(obj, default_share, idx)
.map_err(|e| ErrorData::invalid_params(e, None))?;
let query_root = match get_str(obj, "path").or_else(|| get_str(obj, "file_path")) {
Some(raw) => match ctx.resolve_path_sync(&raw) {
Ok(p) => p,
Err(e) => {
combined.push_str(&format!(
"── query {}: '{}' ──\nERROR: {e}\n\n",
idx + 1,
truncate_query(&pattern, 40)
));
continue;
}
},
None => root.clone(),
};
let search_result = tokio::task::block_in_place(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cached_or_search(
&pattern,
&query_root,
include.as_deref(),
per_query_max,
crp,
respect,
allow_secret_paths,
anchored,
exclude.as_deref(),
exclude_pattern.as_deref(),
)
}))
.ok()
});
let label = if queries.len() > 1 {
format!(
"── query {}: '{}' ──\n",
idx + 1,
truncate_query(&pattern, 40)
)
} else {
String::new()
};
let Some(outcome) = search_result else {
combined.push_str(&format!("{label}ERROR: search panicked\n\n"));
failed_queries += 1;
continue;
};
secret_paths_withheld = secret_paths_withheld.max(outcome.secret_paths_withheld);
if outcome.failed {
failed_queries += 1;
} else {
answered_queries += 1;
}
if !outcome.text.trim().is_empty() {
combined.push_str(&format!("{label}{}\n\n", outcome.text));
total_observed += outcome.observed_tokens;
total_sent += lean_ctx::core::tokens::count_tokens(&outcome.text);
}
}
note_secret_paths_withheld(secret_paths_withheld);
if combined.is_empty() {
combined = "No matches found for any query.".to_string();
}
let final_out = lean_ctx::core::protocol::append_savings(&combined, total_observed, total_sent);
if failed_queries > 0 && answered_queries == 0 {
return Err(crate::server::tool_trait::tool_execution_error(final_out));
}
let saved = total_observed.saturating_sub(total_sent);
lean_ctx::core::savings_ledger::record_tool_event(
"ctx_search",
total_observed,
total_sent,
None,
None,
);
crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
Ok(resolved.annotate(ToolOutput {
text: final_out,
original_tokens: total_observed,
saved_tokens: saved,
mode: None,
path: None,
changed: false,
shell_outcome: None,
content_blocks: None,
}))
}
fn truncate_query(q: &str, max: usize) -> String {
if q.len() <= max {
q.to_string()
} else {
format!("{}...", &q[..q.floor_char_boundary(max)])
}
}
fn ext_to_include(ext: &str) -> String {
if ext.contains(['*', '{', '?', '/']) {
return ext.to_string();
}
let bare = ext.strip_prefix('.').unwrap_or(ext);
format!("*.{bare}")
}
#[cfg(test)]
#[path = "ctx_search_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "ctx_search_mcp_tests.rs"]
mod mcp_tests;