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 let result = if action == "reindex" {
111 if artifacts {
112 crate::tools::ctx_semantic_search::handle_reindex_artifacts(&path, workspace)
113 } else {
114 crate::tools::ctx_semantic_search::handle_reindex(&path)
115 }
116 } else if action == "find_related" {
117 let fp = file_path_param.unwrap_or_default();
118 let line = line_param.unwrap_or(1) as usize;
119 if fp.is_empty() {
120 return Err(ErrorData::invalid_params(
121 "find_related requires file_path and line parameters",
122 None,
123 ));
124 }
125 crate::tools::ctx_semantic_search::handle_find_related(
126 &fp,
127 line,
128 &path,
129 top_k,
130 ctx.crp_mode,
131 )
132 } else {
133 crate::tools::ctx_semantic_search::handle(
134 &query,
135 &path,
136 top_k,
137 ctx.crp_mode,
138 languages.as_deref(),
139 path_glob.as_deref(),
140 mode.as_deref(),
141 Some(workspace),
142 Some(artifacts),
143 )
144 };
145
146 let repeat_hint = if action == "reindex" {
147 String::new()
148 } else if let Some(ref autonomy) = ctx.autonomy {
149 autonomy
150 .track_search(&query, &path)
151 .map(|h| format!("\n{h}"))
152 .unwrap_or_default()
153 } else {
154 String::new()
155 };
156
157 Ok(ToolOutput {
158 text: format!("{result}{repeat_hint}"),
159 original_tokens: 0,
160 saved_tokens: 0,
161 mode: Some("semantic".to_string()),
162 path: None,
163 changed: false,
164 })
165 }
166}