lean_ctx/tools/registered/
ctx_semantic_search.rs1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{
6 get_bool, get_int, get_str, get_str_array, McpTool, ToolContext, ToolOutput,
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 "Semantic code search (BM25 + embeddings/hybrid + reranking). action=reindex|find_related.",
21 json!({
22 "type": "object",
23 "properties": {
24 "query": { "type": "string", "description": "Natural language or symbol search query" },
25 "path": { "type": "string", "description": "Project root to search (default: .)" },
26 "top_k": { "type": "integer", "description": "Number of results (default: 10)" },
27 "action": {
28 "type": "string",
29 "enum": ["search", "reindex", "find_related"],
30 "description": "search (default), reindex to rebuild, find_related for chunk-based similarity"
31 },
32 "mode": {
33 "type": "string",
34 "enum": ["bm25", "dense", "hybrid"],
35 "description": "Search mode (default: hybrid)"
36 },
37 "file_path": {
38 "type": "string",
39 "description": "For find_related: source file path (relative to project root)"
40 },
41 "line": {
42 "type": "integer",
43 "description": "For find_related: line number in the source file"
44 },
45 "languages": {
46 "type": "array",
47 "items": { "type": "string" },
48 "description": "Optional: restrict to languages/extensions"
49 },
50 "path_glob": {
51 "type": "string",
52 "description": "Optional: glob over relative file paths"
53 }
54 },
55 "required": ["query"]
56 }),
57 )
58 }
59
60 fn handle(
61 &self,
62 args: &Map<String, Value>,
63 ctx: &ToolContext,
64 ) -> Result<ToolOutput, ErrorData> {
65 let query = get_str(args, "query")
66 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
67 let path = if let Some(p) = ctx.resolved_path("path") {
68 p.to_string()
69 } else if let Some(err) = ctx.path_error("path") {
70 return Err(ErrorData::invalid_params(format!("path: {err}"), None));
71 } else {
72 ctx.project_root.clone()
73 };
74 let top_k = get_int(args, "top_k").unwrap_or(10) as usize;
75 let action = get_str(args, "action").unwrap_or_default();
76 let mode = get_str(args, "mode");
77 let languages = get_str_array(args, "languages");
78 let path_glob = get_str(args, "path_glob");
79 let workspace = get_bool(args, "workspace").unwrap_or(false);
80 let artifacts = get_bool(args, "artifacts").unwrap_or(false);
81
82 #[cfg(feature = "qdrant")]
83 {
84 let mode_effective = mode
85 .as_deref()
86 .unwrap_or("hybrid")
87 .trim()
88 .to_ascii_lowercase();
89 if action != "reindex"
90 && !artifacts
91 && matches!(mode_effective.as_str(), "dense" | "hybrid")
92 && matches!(
93 crate::core::dense_backend::DenseBackendKind::try_from_env(),
94 Ok(crate::core::dense_backend::DenseBackendKind::Qdrant)
95 )
96 {
97 if let Some(ref session_lock) = ctx.session {
98 let value = format!(
99 "tool=ctx_semantic_search mode={mode_effective} workspace={workspace}"
100 );
101 let mut session = tokio::task::block_in_place(|| session_lock.blocking_write());
102 session.record_manual_evidence("remote:qdrant_query", Some(&value));
103 }
104 }
105 }
106
107 let file_path_param = get_str(args, "file_path");
108 let line_param = get_int(args, "line");
109
110 if let Some(ref cache) = ctx.bm25_cache {
111 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
112 }
113
114 if let Some(ref ps) = ctx.progress_sender {
115 if let Some(sender) = ps
116 .lock()
117 .unwrap_or_else(std::sync::PoisonError::into_inner)
118 .as_ref()
119 {
120 sender.send(0.0, Some(1.0), Some("Starting search...".to_string()));
121 }
122 }
123
124 let result = if action == "reindex" {
125 if let Some(ref ps) = ctx.progress_sender {
126 if let Some(sender) = ps
127 .lock()
128 .unwrap_or_else(std::sync::PoisonError::into_inner)
129 .as_ref()
130 {
131 sender.send(0.0, Some(1.0), Some("Rebuilding BM25 index...".to_string()));
132 }
133 }
134 if artifacts {
135 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
136 } else {
137 crate::tools::ctx_semantic_search::handle_reindex(&path)
138 }
139 } else if action == "find_related" {
140 let fp = file_path_param.unwrap_or_default();
141 let line = line_param.unwrap_or(1) as usize;
142 if fp.is_empty() {
143 return Err(ErrorData::invalid_params(
144 "find_related requires file_path and line parameters",
145 None,
146 ));
147 }
148 crate::tools::ctx_semantic_search::handle_find_related(
149 &fp,
150 line,
151 &path,
152 top_k,
153 ctx.crp_mode,
154 )
155 } else {
156 crate::tools::ctx_semantic_search::handle(
157 &query,
158 &path,
159 top_k,
160 ctx.crp_mode,
161 languages.as_deref(),
162 path_glob.as_deref(),
163 mode.as_deref(),
164 Some(workspace),
165 Some(artifacts),
166 )
167 };
168
169 if let Some(ref ps) = ctx.progress_sender {
170 if let Some(sender) = ps
171 .lock()
172 .unwrap_or_else(std::sync::PoisonError::into_inner)
173 .as_ref()
174 {
175 sender.send(1.0, Some(1.0), Some("Search complete".to_string()));
176 }
177 }
178
179 let repeat_hint = if action == "reindex" {
180 String::new()
181 } else if let Some(ref autonomy) = ctx.autonomy {
182 autonomy
183 .track_search(&query, &path)
184 .map(|h| format!("\n{h}"))
185 .unwrap_or_default()
186 } else {
187 String::new()
188 };
189
190 Ok(ToolOutput {
191 text: format!("{result}{repeat_hint}"),
192 original_tokens: 0,
193 saved_tokens: 0,
194 mode: Some("semantic".to_string()),
195 path: None,
196 changed: false,
197 })
198 }
199}