1use rmcp::model::{CallToolResult, Content, ErrorCode, Tool};
4use serde_json::Value;
5
6use super::{parse_params, schema_for, CallGraphParams, SmartContextParams};
7use crate::mcp::server::CtxServer;
8
9fn internal_error(msg: impl Into<String>) -> rmcp::ErrorData {
11 rmcp::ErrorData::new(ErrorCode::INTERNAL_ERROR, msg.into(), None)
12}
13
14pub fn get_callers_tool() -> Tool {
16 Tool::new(
17 "get_callers",
18 "Find all functions that call a given function. \
19 Useful for understanding the impact of changes and the call hierarchy.",
20 schema_for::<CallGraphParams>(),
21 )
22}
23
24pub fn get_callees_tool() -> Tool {
26 Tool::new(
27 "get_callees",
28 "Find all functions called by a given function. \
29 Useful for understanding dependencies and what a function relies on.",
30 schema_for::<CallGraphParams>(),
31 )
32}
33
34pub fn smart_context_tool() -> Tool {
36 Tool::new(
37 "smart_context",
38 "Intelligently select relevant files for a given task using semantic search \
39 and call graph analysis. Returns the most relevant code for implementing \
40 a feature, fixing a bug, or understanding a concept.",
41 schema_for::<SmartContextParams>(),
42 )
43}
44
45pub async fn get_callers(
47 server: &CtxServer,
48 args: Option<&serde_json::Map<String, Value>>,
49) -> Result<CallToolResult, rmcp::ErrorData> {
50 let params: CallGraphParams = parse_params(args)?;
51
52 let symbols = server
54 .with_db(|db| {
55 db.find_symbols_filtered(
56 ¶ms.function,
57 100,
58 params.file.as_deref(),
59 Some("function"),
60 )
61 })
62 .map_err(|e| internal_error(e.to_string()))?;
63
64 if symbols.is_empty() {
65 return Ok(CallToolResult::success(vec![Content::text(format!(
66 "Function '{}' not found",
67 params.function
68 ))]));
69 }
70
71 let sym = &symbols[0];
72 let sym_name = sym.name.clone();
73
74 let edges = server
76 .with_db(|db| db.get_incoming_edges(&sym_name))
77 .map_err(|e| internal_error(e.to_string()))?;
78
79 if edges.is_empty() {
80 return Ok(CallToolResult::success(vec![Content::text(format!(
81 "No callers found for '{}'",
82 sym.name
83 ))]));
84 }
85
86 let mut output = format!("Functions that call '{}' ({}):\n\n", sym.name, edges.len());
87
88 for edge in &edges {
89 let source_id = edge.source_id.clone();
90 if let Ok(Some(caller)) = server.with_db(|db| db.get_symbol(&source_id)) {
91 output.push_str(&format!(
92 "- {} ({}:{})\n",
93 caller.name,
94 caller.file_path,
95 edge.line.unwrap_or(caller.line_start)
96 ));
97 if let Some(ref ctx) = edge.context {
98 output.push_str(&format!(" Call: {}\n", ctx));
99 }
100 }
101 }
102
103 Ok(CallToolResult::success(vec![Content::text(output)]))
104}
105
106pub async fn get_callees(
108 server: &CtxServer,
109 args: Option<&serde_json::Map<String, Value>>,
110) -> Result<CallToolResult, rmcp::ErrorData> {
111 let params: CallGraphParams = parse_params(args)?;
112
113 let symbols = server
115 .with_db(|db| {
116 db.find_symbols_filtered(
117 ¶ms.function,
118 100,
119 params.file.as_deref(),
120 Some("function"),
121 )
122 })
123 .map_err(|e| internal_error(e.to_string()))?;
124
125 if symbols.is_empty() {
126 return Ok(CallToolResult::success(vec![Content::text(format!(
127 "Function '{}' not found",
128 params.function
129 ))]));
130 }
131
132 let sym = &symbols[0];
133 let sym_id = sym.id.clone();
134
135 let edges = server
137 .with_db(|db| db.get_outgoing_edges(&sym_id))
138 .map_err(|e| internal_error(e.to_string()))?;
139
140 if edges.is_empty() {
141 return Ok(CallToolResult::success(vec![Content::text(format!(
142 "No function calls found in '{}'",
143 sym.name
144 ))]));
145 }
146
147 let mut output = format!("Functions called by '{}' ({}):\n\n", sym.name, edges.len());
148
149 for edge in &edges {
150 output.push_str(&format!(
151 "- {} [{}] (line {})\n",
152 edge.target_name,
153 edge.kind.as_str(),
154 edge.line.unwrap_or(0)
155 ));
156 }
157
158 Ok(CallToolResult::success(vec![Content::text(output)]))
159}
160
161pub async fn smart_context(
163 server: &CtxServer,
164 args: Option<&serde_json::Map<String, Value>>,
165) -> Result<CallToolResult, rmcp::ErrorData> {
166 use crate::embeddings::local::LocalProvider;
167 use crate::embeddings::ollama::OllamaProvider;
168 use crate::embeddings::openai::OpenAIProvider;
169 use crate::embeddings::{Embedding, EmbeddingProvider, Provider};
170 use crate::smart::{smart_context_with_embedding, SmartConfig};
171 use crate::tokens::Encoding;
172
173 let params: SmartContextParams = parse_params(args)?;
174
175 let embedding_count = server
177 .with_db(|db| db.count_embeddings())
178 .map_err(|e| internal_error(e.to_string()))?;
179
180 if embedding_count == 0 {
181 return Err(internal_error(
182 "No embeddings found. Run 'ctx embed' first to generate embeddings.",
183 ));
184 }
185
186 let has_analytics = server.with_analytics(|_| ()).is_some();
188 if !has_analytics {
189 return Err(internal_error(
190 "Analytics not available. Run 'ctx index' first.",
191 ));
192 }
193
194 let config = SmartConfig {
196 max_tokens: params.max_tokens.unwrap_or(8000),
197 depth: params.depth.unwrap_or(2),
198 top: params.top.unwrap_or(10),
199 encoding: Encoding::default(),
200 };
201
202 let project_config =
207 crate::config::CtxConfig::load(&std::env::current_dir().unwrap_or_default());
208 let provider = match params.provider.as_deref() {
209 Some("openai") => Provider::Openai,
210 Some("ollama") => Provider::Ollama,
211 Some("local") => Provider::Local,
212 None => Provider::resolve(
213 None,
214 params.use_openai.unwrap_or(false),
215 project_config.embedding.provider,
216 ),
217 Some(other) => {
218 return Err(internal_error(format!(
219 "Unknown provider '{}'. Expected: local, openai, or ollama.",
220 other
221 )))
222 }
223 };
224
225 let task_embedding: Embedding = match provider {
226 Provider::Openai => {
227 let provider = OpenAIProvider::from_env().map_err(|e| {
228 internal_error(format!(
229 "Failed to initialize OpenAI provider: {}. Set OPENAI_API_KEY environment variable.",
230 e
231 ))
232 })?;
233 provider
234 .embed_async(¶ms.task)
235 .await
236 .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))?
237 }
238 Provider::Ollama => {
239 let provider = OllamaProvider::from_config_async(
240 project_config.embedding.model.as_deref(),
241 project_config.embedding.host.as_deref(),
242 )
243 .await
244 .map_err(|e| internal_error(format!("Failed to initialize Ollama provider: {}", e)))?;
245 provider
246 .embed_async(¶ms.task)
247 .await
248 .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))?
249 }
250 Provider::Local => {
251 let provider = LocalProvider::new().map_err(|e| {
253 internal_error(format!("Failed to initialize embedding model: {}", e))
254 })?;
255 provider
256 .embed(¶ms.task)
257 .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))?
258 }
259 };
260
261 let result = {
263 let db = server.db.lock().unwrap();
264 let analytics = server
265 .analytics
266 .as_ref()
267 .ok_or_else(|| internal_error("Analytics not available"))?
268 .lock()
269 .unwrap();
270
271 smart_context_with_embedding(&db, &analytics, ¶ms.task, &task_embedding, config)
272 }
273 .map_err(|e| internal_error(format!("Smart context selection failed: {}", e)))?;
274
275 if result.selected_files.is_empty() {
276 return Ok(CallToolResult::success(vec![Content::text(format!(
277 "No relevant files found for task: \"{}\"",
278 params.task
279 ))]));
280 }
281
282 let mut output = format!("Smart context for: \"{}\"\n\n", params.task);
284 output.push_str(&format!(
285 "Selected {} files ({} tokens){}:\n\n",
286 result.selected_files.len(),
287 result.total_tokens,
288 if result.truncated {
289 format!(", {} omitted due to token limit", result.omitted_count)
290 } else {
291 String::new()
292 }
293 ));
294
295 for file in &result.selected_files {
296 output.push_str(&format!(
297 "- {} (relevance: {:.0}%, {} tokens)\n",
298 file.path,
299 file.relevance_score * 100.0,
300 file.token_count
301 ));
302 for reason in &file.reasons {
303 output.push_str(&format!(" - {:?}\n", reason));
304 }
305 }
306
307 output.push_str("\n---\n\nSelected file contents:\n\n");
309
310 let root = server.root();
311 for file in &result.selected_files {
312 let path = root.join(&file.path);
313 if let Ok(content) = std::fs::read_to_string(&path) {
314 output.push_str(&format!("// === {} ===\n\n", file.path));
315 output.push_str(&content);
316 output.push_str("\n\n");
317 }
318 }
319
320 Ok(CallToolResult::success(vec![Content::text(output)]))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_get_callers_tool_definition() {
329 let tool = get_callers_tool();
330 assert_eq!(tool.name.as_ref(), "get_callers");
331 assert!(tool.description.is_some());
332 }
333
334 #[test]
335 fn test_get_callees_tool_definition() {
336 let tool = get_callees_tool();
337 assert_eq!(tool.name.as_ref(), "get_callees");
338 assert!(tool.description.is_some());
339 }
340
341 #[test]
342 fn test_smart_context_tool_definition() {
343 let tool = smart_context_tool();
344 assert_eq!(tool.name.as_ref(), "smart_context");
345 assert!(tool.description.is_some());
346 }
347}