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