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            "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}