Skip to main content

lean_ctx/tools/registered/
ctx_semantic_search.rs

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