lean_ctx/tools/registered/
ctx_semantic_search.rs1use 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 CtxSemanticSearchTool;
11
12impl McpTool for CtxSemanticSearchTool {
13 fn name(&self) -> &'static str {
14 "ctx_semantic_search"
15 }
16
17 fn tool_def(&self) -> Tool {
18 tool_def(
19 "ctx_semantic_search",
20 "Search code by MEANING (BM25+embeddings) — use when you know the concept but not the exact\n\
21 symbol name. query='user auth' finds relevant code even with no keyword match.\n\
22 Different from ctx_search (regex): use ctx_search for exact patterns, this for\n\
23 fuzzy/conceptual. For understanding code end-to-end, use ctx_compose FIRST.\n\
24 find_related(file_path, line) for context neighbors. mode=bm25|dense|hybrid.",
25 json!({
26 "type": "object",
27 "properties": {
28 "query": { "type": "string", "description": "Natural language or symbol query" },
29 "path": { "type": "string", "description": "Project root" },
30 "top_k": { "type": "integer", "description": "Max results (default: 10)" },
31 "action": {
32 "type": "string",
33 "enum": ["search", "reindex", "find_related"]
34 },
35 "mode": {
36 "type": "string",
37 "enum": ["bm25", "dense", "hybrid"]
38 },
39 "file_path": { "type": "string", "description": "Source file for find_related" },
40 "line": { "type": "integer", "description": "Line for find_related" },
41 "languages": {
42 "type": "array",
43 "items": { "type": "string" },
44 "description": "Restrict to extensions, e.g. ['rust','ts']"
45 },
46 "path_glob": { "type": "string", "description": "Glob over relative file paths" }
47 },
48 "required": ["query"]
49 }),
50 )
51 }
52
53 fn handle(
54 &self,
55 args: &Map<String, Value>,
56 ctx: &ToolContext,
57 ) -> Result<ToolOutput, ErrorData> {
58 let query = get_str(args, "query")
59 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
60 let path = if let Some(p) = ctx.resolved_path("path") {
61 p.to_string()
62 } else if let Some(err) = ctx.path_error("path") {
63 return Err(ErrorData::invalid_params(format!("path: {err}"), None));
64 } else {
65 ctx.project_root.clone()
66 };
67 let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
68 let action = get_str(args, "action").unwrap_or_default();
69 let mode = get_str(args, "mode");
70 let languages = get_str_array(args, "languages");
71 let path_glob = get_str(args, "path_glob");
72 let workspace = get_bool(args, "workspace").unwrap_or(false);
73 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
74
75 #[cfg(feature = "qdrant")]
76 {
77 let mode_effective = mode
78 .as_deref()
79 .unwrap_or("hybrid")
80 .trim()
81 .to_ascii_lowercase();
82 if action != "reindex"
83 && !artifacts
84 && matches!(mode_effective.as_str(), "dense" | "hybrid")
85 && matches!(
86 crate::core::dense_backend::DenseBackendKind::try_from_env(),
87 Ok(crate::core::dense_backend::DenseBackendKind::Qdrant)
88 )
89 && let Some(ref session_lock) = ctx.session
90 {
91 let value =
92 format!("tool=ctx_semantic_search mode={mode_effective} workspace={workspace}");
93 let mut session = tokio::task::block_in_place(|| session_lock.blocking_write());
94 session.record_manual_evidence("remote:qdrant_query", Some(&value));
95 }
96 }
97
98 if let Some(ref cache) = ctx.bm25_cache {
99 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
100 }
101
102 let send_progress = |progress: f64, msg: &str| {
103 #[allow(clippy::unwrap_or_default)]
104 if let Some(ref ps) = ctx.progress_sender
105 && let Some(sender) = ps
106 .lock()
107 .unwrap_or_else(std::sync::PoisonError::into_inner)
108 .as_ref()
109 {
110 sender.send(progress, Some(1.0), Some(msg.to_string()));
111 }
112 };
113
114 send_progress(0.0, "Starting search...");
115
116 let result = if action == "reindex" {
117 send_progress(0.0, "Rebuilding BM25 index...");
118 if artifacts {
119 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
120 } else {
121 crate::tools::ctx_semantic_search::handle_reindex(&path)
122 }
123 } else if action == "find_related" {
124 let fp = get_str(args, "file_path").unwrap_or_default();
125 let line = get_int(args, "line").unwrap_or(1) as usize;
126 if fp.is_empty() {
127 return Err(ErrorData::invalid_params(
128 "find_related requires file_path and line parameters",
129 None,
130 ));
131 }
132 crate::tools::ctx_semantic_search::handle_find_related(
133 &fp,
134 line,
135 &path,
136 top_k,
137 ctx.crp_mode,
138 )
139 } else {
140 crate::tools::ctx_semantic_search::handle(
141 &query,
142 &path,
143 top_k,
144 ctx.crp_mode,
145 languages.as_deref(),
146 path_glob.as_deref(),
147 mode.as_deref(),
148 Some(workspace),
149 Some(artifacts),
150 )
151 };
152
153 send_progress(1.0, "Search complete");
154
155 let repeat_hint = if action == "reindex" {
156 String::new()
157 } else if let Some(ref autonomy) = ctx.autonomy {
158 autonomy
159 .track_search(&query, &path)
160 .map(|h| format!("\n{h}"))
161 .unwrap_or_default()
162 } else {
163 String::new()
164 };
165
166 Ok(ToolOutput {
167 text: format!("{result}{repeat_hint}"),
168 original_tokens: 0,
169 saved_tokens: 0,
170 mode: Some("semantic".to_string()),
171 path: None,
172 changed: false,
173 shell_outcome: None,
174 })
175 }
176}