Skip to main content

lean_ctx/tools/registered/
ctx_semantic_search.rs

1use 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}