Skip to main content

ctx/mcp/tools/
analysis.rs

1//! Analysis-related MCP tools.
2
3use 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
9/// Helper to create an internal error.
10fn internal_error(msg: impl Into<String>) -> rmcp::ErrorData {
11    rmcp::ErrorData::new(ErrorCode::INTERNAL_ERROR, msg.into(), None)
12}
13
14/// Create the get_callers tool definition.
15pub 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
24/// Create the get_callees tool definition.
25pub 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
34/// Create the smart_context tool definition.
35pub 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
45/// Execute the get_callers tool.
46pub 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    // Find the function first
53    let symbols = server
54        .with_db(|db| {
55            db.find_symbols_filtered(
56                &params.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    // Get incoming edges (callers)
75    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
106/// Execute the get_callees tool.
107pub 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    // Find the function first
114    let symbols = server
115        .with_db(|db| {
116            db.find_symbols_filtered(
117                &params.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    // Get outgoing edges (callees)
136    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
161/// Execute the smart_context tool.
162pub 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    // Check if embeddings exist
176    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    // Check if analytics is available
187    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    // Configure smart context
195    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    // Resolve provider: explicit `provider` string wins, else the deprecated
203    // `use_openai` bool, else the `.ctx/config.toml` default, else local. Network
204    // providers embed asynchronously so they don't block the async runtime.
205    // (Named `project_config` so it doesn't shadow the `SmartConfig` above.)
206    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(&params.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(&params.task)
247                .await
248                .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))?
249        }
250        Provider::Local => {
251            // Local fastembed is CPU-bound; sync embed is fine.
252            let provider = LocalProvider::new().map_err(|e| {
253                internal_error(format!("Failed to initialize embedding model: {}", e))
254            })?;
255            provider
256                .embed(&params.task)
257                .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))?
258        }
259    };
260
261    // Run smart context selection with pre-computed embedding
262    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, &params.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    // Format output
283    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    // Include the actual file contents if they fit
308    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}