gabb_cli/
mcp.rs

1//! MCP (Model Context Protocol) server implementation.
2//!
3//! This module implements an MCP server that exposes gabb's code indexing
4//! capabilities as tools for AI assistants like Claude.
5//!
6//! Supports dynamic workspace detection - workspaces are automatically inferred
7//! from file paths passed to tools, enabling one MCP server to handle multiple
8//! projects.
9
10use crate::store::{DuplicateGroup, IndexStore, SymbolRecord};
11use anyhow::{bail, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use std::collections::HashMap;
15use std::io::{self, BufRead, Write};
16use std::path::{Path, PathBuf};
17
18/// Workspace markers - files/directories that indicate a project root
19const WORKSPACE_MARKERS: &[&str] = &[
20    ".git",
21    ".gabb",
22    "Cargo.toml",
23    "package.json",
24    "go.mod",
25    "settings.gradle",
26    "settings.gradle.kts",
27    "pyproject.toml",
28    "pom.xml",
29    "build.gradle",
30    "build.gradle.kts",
31];
32
33/// Directory markers - directories that indicate a project root
34const WORKSPACE_DIR_MARKERS: &[&str] = &["gradle", ".git"];
35
36/// Maximum number of workspaces to cache (LRU eviction)
37const MAX_CACHED_WORKSPACES: usize = 5;
38
39/// MCP Protocol version
40const PROTOCOL_VERSION: &str = "2024-11-05";
41
42/// Server info
43const SERVER_NAME: &str = "gabb";
44const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
45
46// ==================== JSON-RPC Types ====================
47
48#[derive(Debug, Deserialize)]
49#[allow(dead_code)]
50struct JsonRpcRequest {
51    jsonrpc: String,
52    id: Option<Value>,
53    method: String,
54    #[serde(default)]
55    params: Value,
56}
57
58#[derive(Debug, Serialize)]
59struct JsonRpcResponse {
60    jsonrpc: String,
61    id: Value,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    result: Option<Value>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    error: Option<JsonRpcError>,
66}
67
68#[derive(Debug, Serialize)]
69struct JsonRpcError {
70    code: i32,
71    message: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    data: Option<Value>,
74}
75
76impl JsonRpcResponse {
77    fn success(id: Value, result: Value) -> Self {
78        Self {
79            jsonrpc: "2.0".to_string(),
80            id,
81            result: Some(result),
82            error: None,
83        }
84    }
85
86    fn error(id: Value, code: i32, message: impl Into<String>) -> Self {
87        Self {
88            jsonrpc: "2.0".to_string(),
89            id,
90            result: None,
91            error: Some(JsonRpcError {
92                code,
93                message: message.into(),
94                data: None,
95            }),
96        }
97    }
98}
99
100// JSON-RPC error codes
101const PARSE_ERROR: i32 = -32700;
102const INTERNAL_ERROR: i32 = -32603;
103
104// ==================== MCP Types ====================
105
106#[derive(Debug, Serialize)]
107struct Tool {
108    name: String,
109    description: String,
110    #[serde(rename = "inputSchema")]
111    input_schema: Value,
112}
113
114#[derive(Debug, Serialize)]
115struct ToolResult {
116    content: Vec<ToolContent>,
117    #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
118    is_error: Option<bool>,
119}
120
121#[derive(Debug, Serialize)]
122struct ToolContent {
123    #[serde(rename = "type")]
124    content_type: String,
125    text: String,
126}
127
128impl ToolResult {
129    fn text(text: impl Into<String>) -> Self {
130        Self {
131            content: vec![ToolContent {
132                content_type: "text".to_string(),
133                text: text.into(),
134            }],
135            is_error: None,
136        }
137    }
138
139    fn error(message: impl Into<String>) -> Self {
140        Self {
141            content: vec![ToolContent {
142                content_type: "text".to_string(),
143                text: message.into(),
144            }],
145            is_error: Some(true),
146        }
147    }
148}
149
150// ==================== MCP Server ====================
151
152/// Cached workspace information
153struct WorkspaceInfo {
154    root: PathBuf,
155    db_path: PathBuf,
156    store: Option<IndexStore>,
157    last_used: std::time::Instant,
158}
159
160/// MCP Server state with multi-workspace support
161pub struct McpServer {
162    /// Default workspace (from --root flag), used when no file path is provided
163    default_workspace: PathBuf,
164    /// Default database path
165    default_db_path: PathBuf,
166    /// Cache of workspace -> store mappings
167    workspace_cache: HashMap<PathBuf, WorkspaceInfo>,
168    /// Whether the MCP client has sent initialized notification
169    initialized: bool,
170}
171
172impl McpServer {
173    pub fn new(workspace_root: PathBuf, db_path: PathBuf) -> Self {
174        Self {
175            default_workspace: workspace_root,
176            default_db_path: db_path,
177            workspace_cache: HashMap::new(),
178            initialized: false,
179        }
180    }
181
182    /// Run the MCP server, reading from stdin and writing to stdout.
183    pub fn run(&mut self) -> Result<()> {
184        let stdin = io::stdin();
185        let mut stdout = io::stdout();
186
187        for line in stdin.lock().lines() {
188            let line = line?;
189            if line.is_empty() {
190                continue;
191            }
192
193            let response = self.handle_message(&line);
194            if let Some(response) = response {
195                let json = serde_json::to_string(&response)?;
196                writeln!(stdout, "{}", json)?;
197                stdout.flush()?;
198            }
199        }
200
201        Ok(())
202    }
203
204    fn handle_message(&mut self, line: &str) -> Option<JsonRpcResponse> {
205        // Parse the JSON-RPC request
206        let request: JsonRpcRequest = match serde_json::from_str(line) {
207            Ok(req) => req,
208            Err(e) => {
209                return Some(JsonRpcResponse::error(
210                    Value::Null,
211                    PARSE_ERROR,
212                    format!("Parse error: {}", e),
213                ));
214            }
215        };
216
217        // Notifications don't get responses
218        let id = match request.id {
219            Some(id) => id,
220            None => {
221                // Handle notification (no response needed)
222                self.handle_notification(&request.method, &request.params);
223                return None;
224            }
225        };
226
227        // Handle the request
228        match self.handle_request(&request.method, &request.params) {
229            Ok(result) => Some(JsonRpcResponse::success(id, result)),
230            Err(e) => Some(JsonRpcResponse::error(id, INTERNAL_ERROR, e.to_string())),
231        }
232    }
233
234    fn handle_notification(&mut self, method: &str, _params: &Value) {
235        match method {
236            "notifications/initialized" => {
237                self.initialized = true;
238                log::info!("MCP client initialized");
239            }
240            _ => {
241                log::debug!("Unknown notification: {}", method);
242            }
243        }
244    }
245
246    fn handle_request(&mut self, method: &str, params: &Value) -> Result<Value> {
247        match method {
248            "initialize" => self.handle_initialize(params),
249            "tools/list" => self.handle_tools_list(),
250            "tools/call" => self.handle_tools_call(params),
251            _ => bail!("Method not found: {}", method),
252        }
253    }
254
255    fn handle_initialize(&mut self, _params: &Value) -> Result<Value> {
256        // Ensure default workspace index is available (auto-start daemon if needed)
257        let default_workspace = self.default_workspace.clone();
258        self.ensure_workspace_index(&default_workspace)?;
259
260        Ok(json!({
261            "protocolVersion": PROTOCOL_VERSION,
262            "capabilities": {
263                "tools": {
264                    "listChanged": false
265                }
266            },
267            "serverInfo": {
268                "name": SERVER_NAME,
269                "version": SERVER_VERSION
270            }
271        }))
272    }
273
274    fn handle_tools_list(&self) -> Result<Value> {
275        let tools = vec![
276            Tool {
277                name: "gabb_symbols".to_string(),
278                description: concat!(
279                    "Search for code symbols (functions, classes, interfaces, types, structs, enums, traits) in the indexed codebase. ",
280                    "USE THIS INSTEAD OF grep/ripgrep when: finding where a function or class is defined, ",
281                    "exploring what methods/functions exist, listing symbols in a file, or searching by symbol kind. ",
282                    "Returns precise file:line:column locations. Faster and more accurate than text search for code navigation. ",
283                    "Supports TypeScript, Rust, and Kotlin."
284                ).to_string(),
285                input_schema: json!({
286                    "type": "object",
287                    "properties": {
288                        "name": {
289                            "type": "string",
290                            "description": "Filter by symbol name (exact match). Use this when you know the name you're looking for."
291                        },
292                        "kind": {
293                            "type": "string",
294                            "description": "Filter by symbol kind: function, class, interface, type, struct, enum, trait, method, const, variable"
295                        },
296                        "file": {
297                            "type": "string",
298                            "description": "Filter to symbols in this file path. Use to explore a specific file's structure."
299                        },
300                        "limit": {
301                            "type": "integer",
302                            "description": "Maximum number of results (default: 50). Increase for comprehensive searches."
303                        }
304                    }
305                }),
306            },
307            Tool {
308                name: "gabb_symbol".to_string(),
309                description: concat!(
310                    "Get detailed information about a symbol when you know its name. ",
311                    "USE THIS when you have a specific symbol name and want to find where it's defined. ",
312                    "Returns the symbol's location, kind, visibility, and container. ",
313                    "For exploring unknown code, use gabb_symbols instead."
314                ).to_string(),
315                input_schema: json!({
316                    "type": "object",
317                    "properties": {
318                        "name": {
319                            "type": "string",
320                            "description": "The exact symbol name to look up (e.g., 'MyClass', 'process_data', 'UserService')"
321                        },
322                        "kind": {
323                            "type": "string",
324                            "description": "Optionally filter by kind if the name is ambiguous (function, class, interface, etc.)"
325                        }
326                    },
327                    "required": ["name"]
328                }),
329            },
330            Tool {
331                name: "gabb_definition".to_string(),
332                description: concat!(
333                    "Jump from a symbol usage to its definition/declaration. ",
334                    "USE THIS when you see a function call, type reference, or variable and want to see where it's defined. ",
335                    "Works across files and through imports. Provide the file and position where the symbol is USED, ",
336                    "and this returns where it's DEFINED. Essential for understanding unfamiliar code."
337                ).to_string(),
338                input_schema: json!({
339                    "type": "object",
340                    "properties": {
341                        "file": {
342                            "type": "string",
343                            "description": "Path to the file containing the symbol usage (absolute or relative to workspace)"
344                        },
345                        "line": {
346                            "type": "integer",
347                            "description": "1-based line number where the symbol appears"
348                        },
349                        "character": {
350                            "type": "integer",
351                            "description": "1-based column number (position within the line)"
352                        }
353                    },
354                    "required": ["file", "line", "character"]
355                }),
356            },
357            Tool {
358                name: "gabb_usages".to_string(),
359                description: concat!(
360                    "Find ALL places where a symbol is used/referenced across the codebase. ",
361                    "USE THIS BEFORE REFACTORING to understand impact, when investigating how a function is called, ",
362                    "or to find all consumers of an API. More accurate than text search - understands code structure ",
363                    "and won't match comments or strings. Point to a symbol definition to find all its usages."
364                ).to_string(),
365                input_schema: json!({
366                    "type": "object",
367                    "properties": {
368                        "file": {
369                            "type": "string",
370                            "description": "Path to the file containing the symbol definition"
371                        },
372                        "line": {
373                            "type": "integer",
374                            "description": "1-based line number of the symbol"
375                        },
376                        "character": {
377                            "type": "integer",
378                            "description": "1-based column number within the line"
379                        },
380                        "limit": {
381                            "type": "integer",
382                            "description": "Maximum usages to return (default: 50). Increase for thorough analysis."
383                        }
384                    },
385                    "required": ["file", "line", "character"]
386                }),
387            },
388            Tool {
389                name: "gabb_implementations".to_string(),
390                description: concat!(
391                    "Find all implementations of an interface, trait, or abstract class. ",
392                    "USE THIS when you have an interface/trait and want to find concrete implementations, ",
393                    "or when exploring a codebase's architecture to understand what classes implement a contract. ",
394                    "Point to the interface/trait definition to find all implementing classes/structs."
395                ).to_string(),
396                input_schema: json!({
397                    "type": "object",
398                    "properties": {
399                        "file": {
400                            "type": "string",
401                            "description": "Path to the file containing the interface/trait definition"
402                        },
403                        "line": {
404                            "type": "integer",
405                            "description": "1-based line number of the interface/trait"
406                        },
407                        "character": {
408                            "type": "integer",
409                            "description": "1-based column number within the line"
410                        },
411                        "limit": {
412                            "type": "integer",
413                            "description": "Maximum implementations to return (default: 50)"
414                        }
415                    },
416                    "required": ["file", "line", "character"]
417                }),
418            },
419            Tool {
420                name: "gabb_daemon_status".to_string(),
421                description: concat!(
422                    "Check if the gabb indexing daemon is running and get workspace info. ",
423                    "USE THIS to diagnose issues if other gabb tools aren't working, ",
424                    "or to verify the index is up-to-date. Returns daemon PID, version, and index location."
425                ).to_string(),
426                input_schema: json!({
427                    "type": "object",
428                    "properties": {}
429                }),
430            },
431            Tool {
432                name: "gabb_duplicates".to_string(),
433                description: concat!(
434                    "Find duplicate or near-duplicate code blocks in the codebase. ",
435                    "USE THIS to identify copy-paste code, find refactoring opportunities, ",
436                    "or detect code that should be consolidated into shared utilities. ",
437                    "Groups symbols (functions, methods, classes) by identical content hash. ",
438                    "Can filter by symbol kind or focus on recently changed files."
439                ).to_string(),
440                input_schema: json!({
441                    "type": "object",
442                    "properties": {
443                        "kind": {
444                            "type": "string",
445                            "description": "Filter by symbol kind: function, method, class, etc. Useful to focus on function duplicates."
446                        },
447                        "min_count": {
448                            "type": "integer",
449                            "description": "Minimum number of duplicates to report (default: 2). Increase to find more widespread duplication."
450                        },
451                        "limit": {
452                            "type": "integer",
453                            "description": "Maximum number of duplicate groups to return (default: 20)"
454                        }
455                    }
456                }),
457            },
458        ];
459
460        Ok(json!({ "tools": tools }))
461    }
462
463    fn handle_tools_call(&mut self, params: &Value) -> Result<Value> {
464        let name = params
465            .get("name")
466            .and_then(|v| v.as_str())
467            .ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
468
469        let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
470
471        let result = match name {
472            "gabb_symbols" => self.tool_symbols(&arguments),
473            "gabb_symbol" => self.tool_symbol(&arguments),
474            "gabb_definition" => self.tool_definition(&arguments),
475            "gabb_usages" => self.tool_usages(&arguments),
476            "gabb_implementations" => self.tool_implementations(&arguments),
477            "gabb_daemon_status" => self.tool_daemon_status(),
478            "gabb_duplicates" => self.tool_duplicates(&arguments),
479            _ => Ok(ToolResult::error(format!("Unknown tool: {}", name))),
480        }?;
481
482        Ok(serde_json::to_value(result)?)
483    }
484
485    // ==================== Workspace Management ====================
486
487    /// Infer workspace root from a file path by walking up to find markers
488    fn infer_workspace(&self, file_path: &Path) -> Option<PathBuf> {
489        let file_path = if file_path.is_absolute() {
490            file_path.to_path_buf()
491        } else {
492            self.default_workspace.join(file_path)
493        };
494
495        let mut current = file_path.parent()?;
496
497        loop {
498            // Check file markers
499            for marker in WORKSPACE_MARKERS {
500                if current.join(marker).exists() {
501                    return Some(current.to_path_buf());
502                }
503            }
504
505            // Check directory markers
506            for marker in WORKSPACE_DIR_MARKERS {
507                let marker_path = current.join(marker);
508                if marker_path.is_dir() {
509                    return Some(current.to_path_buf());
510                }
511            }
512
513            // Move up to parent directory
514            current = current.parent()?;
515        }
516    }
517
518    /// Get or create workspace info for a given workspace root
519    fn get_or_create_workspace(&mut self, workspace_root: &Path) -> Result<&mut WorkspaceInfo> {
520        let workspace_root = workspace_root
521            .canonicalize()
522            .unwrap_or_else(|_| workspace_root.to_path_buf());
523
524        // Evict oldest workspace if cache is full
525        if !self.workspace_cache.contains_key(&workspace_root)
526            && self.workspace_cache.len() >= MAX_CACHED_WORKSPACES
527        {
528            // Find oldest entry
529            if let Some(oldest_key) = self
530                .workspace_cache
531                .iter()
532                .min_by_key(|(_, info)| info.last_used)
533                .map(|(k, _)| k.clone())
534            {
535                log::debug!("Evicting workspace from cache: {}", oldest_key.display());
536                self.workspace_cache.remove(&oldest_key);
537            }
538        }
539
540        // Insert if not present
541        if !self.workspace_cache.contains_key(&workspace_root) {
542            let db_path = workspace_root.join(".gabb/index.db");
543            log::debug!(
544                "Adding workspace to cache: {} (db: {})",
545                workspace_root.display(),
546                db_path.display()
547            );
548            self.workspace_cache.insert(
549                workspace_root.clone(),
550                WorkspaceInfo {
551                    root: workspace_root.clone(),
552                    db_path,
553                    store: None,
554                    last_used: std::time::Instant::now(),
555                },
556            );
557        }
558
559        // Update last_used and return
560        let info = self.workspace_cache.get_mut(&workspace_root).unwrap();
561        info.last_used = std::time::Instant::now();
562        Ok(info)
563    }
564
565    /// Ensure index exists for a workspace, starting daemon if needed
566    fn ensure_workspace_index(&mut self, workspace_root: &Path) -> Result<()> {
567        use crate::daemon;
568
569        let info = self.get_or_create_workspace(workspace_root)?;
570
571        if info.store.is_some() {
572            return Ok(());
573        }
574
575        if !info.db_path.exists() {
576            // Start daemon in background
577            log::info!(
578                "Index not found for {}. Starting daemon...",
579                info.root.display()
580            );
581            daemon::start(&info.root, &info.db_path, false, true, None)?;
582
583            // Wait for index to be ready
584            let max_wait = std::time::Duration::from_secs(60);
585            let start = std::time::Instant::now();
586            let db_path = info.db_path.clone();
587            while !db_path.exists() && start.elapsed() < max_wait {
588                std::thread::sleep(std::time::Duration::from_millis(500));
589            }
590
591            if !db_path.exists() {
592                bail!("Daemon started but index not created within 60 seconds");
593            }
594        }
595
596        // Re-get info (borrow checker)
597        let info = self.workspace_cache.get_mut(workspace_root).unwrap();
598        info.store = Some(IndexStore::open(&info.db_path)?);
599        Ok(())
600    }
601
602    /// Get workspace root for a file, falling back to default
603    fn workspace_for_file(&self, file_path: Option<&str>) -> PathBuf {
604        if let Some(path) = file_path {
605            let path = PathBuf::from(path);
606            if let Some(workspace) = self.infer_workspace(&path) {
607                return workspace;
608            }
609        }
610        self.default_workspace.clone()
611    }
612
613    /// Get store for a workspace (ensures index exists)
614    fn get_store_for_workspace(&mut self, workspace_root: &Path) -> Result<&IndexStore> {
615        self.ensure_workspace_index(workspace_root)?;
616        let info = self.workspace_cache.get(workspace_root).unwrap();
617        info.store
618            .as_ref()
619            .ok_or_else(|| anyhow::anyhow!("Store not initialized"))
620    }
621
622    // ==================== Tool Implementations ====================
623
624    fn tool_symbols(&mut self, args: &Value) -> Result<ToolResult> {
625        let name = args.get("name").and_then(|v| v.as_str());
626        let kind = args.get("kind").and_then(|v| v.as_str());
627        let file = args.get("file").and_then(|v| v.as_str());
628        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
629
630        // Infer workspace from file path if provided
631        let workspace = self.workspace_for_file(file);
632        let store = self.get_store_for_workspace(&workspace)?;
633
634        let symbols = store.list_symbols(file, kind, name, Some(limit))?;
635
636        if symbols.is_empty() {
637            return Ok(ToolResult::text("No symbols found matching the criteria."));
638        }
639
640        let output = format_symbols(&symbols, &workspace);
641        Ok(ToolResult::text(output))
642    }
643
644    fn tool_symbol(&mut self, args: &Value) -> Result<ToolResult> {
645        // Use default workspace since we don't have a file path
646        let workspace = self.default_workspace.clone();
647        let store = self.get_store_for_workspace(&workspace)?;
648
649        let name = args
650            .get("name")
651            .and_then(|v| v.as_str())
652            .ok_or_else(|| anyhow::anyhow!("Missing 'name' argument"))?;
653
654        let kind = args.get("kind").and_then(|v| v.as_str());
655
656        let symbols = store.list_symbols(None, kind, Some(name), Some(10))?;
657
658        if symbols.is_empty() {
659            return Ok(ToolResult::text(format!(
660                "No symbol found with name '{}'",
661                name
662            )));
663        }
664
665        let output = format_symbols(&symbols, &workspace);
666        Ok(ToolResult::text(output))
667    }
668
669    fn tool_definition(&mut self, args: &Value) -> Result<ToolResult> {
670        let file = args
671            .get("file")
672            .and_then(|v| v.as_str())
673            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
674        let line = args
675            .get("line")
676            .and_then(|v| v.as_u64())
677            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
678        let character = args
679            .get("character")
680            .and_then(|v| v.as_u64())
681            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
682            as usize;
683
684        // Infer workspace from file path
685        let workspace = self.workspace_for_file(Some(file));
686        let file_path = self.resolve_path_for_workspace(file, &workspace);
687
688        // Find symbol at position
689        if let Some(symbol) = self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
690            let output = format_symbol(&symbol, &workspace);
691            return Ok(ToolResult::text(format!("Definition:\n{}", output)));
692        }
693
694        Ok(ToolResult::text(format!(
695            "No symbol found at {}:{}:{}",
696            file, line, character
697        )))
698    }
699
700    fn tool_usages(&mut self, args: &Value) -> Result<ToolResult> {
701        let file = args
702            .get("file")
703            .and_then(|v| v.as_str())
704            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
705        let line = args
706            .get("line")
707            .and_then(|v| v.as_u64())
708            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
709        let character = args
710            .get("character")
711            .and_then(|v| v.as_u64())
712            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
713            as usize;
714        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
715
716        // Infer workspace from file path
717        let workspace = self.workspace_for_file(Some(file));
718        let file_path = self.resolve_path_for_workspace(file, &workspace);
719
720        // Find symbol at position
721        let symbol = match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
722            Some(s) => s,
723            None => {
724                return Ok(ToolResult::text(format!(
725                    "No symbol found at {}:{}:{}",
726                    file, line, character
727                )));
728            }
729        };
730
731        // Find references using references_for_symbol
732        let store = self.get_store_for_workspace(&workspace)?;
733        let refs = store.references_for_symbol(&symbol.id)?;
734
735        if refs.is_empty() {
736            return Ok(ToolResult::text(format!(
737                "No usages found for '{}'",
738                symbol.name
739            )));
740        }
741
742        let refs: Vec<_> = refs.into_iter().take(limit).collect();
743        let mut output = format!("Usages of '{}' ({} found):\n\n", symbol.name, refs.len());
744        for r in &refs {
745            let rel_path = relative_path_for_workspace(&r.file, &workspace);
746            // Convert offset to line:col
747            if let Ok((ref_line, ref_col)) = offset_to_line_col(&r.file, r.start as usize) {
748                output.push_str(&format!("  {}:{}:{}\n", rel_path, ref_line, ref_col));
749            }
750        }
751
752        Ok(ToolResult::text(output))
753    }
754
755    fn tool_implementations(&mut self, args: &Value) -> Result<ToolResult> {
756        let file = args
757            .get("file")
758            .and_then(|v| v.as_str())
759            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
760        let line = args
761            .get("line")
762            .and_then(|v| v.as_u64())
763            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
764        let character = args
765            .get("character")
766            .and_then(|v| v.as_u64())
767            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
768            as usize;
769        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
770
771        // Infer workspace from file path
772        let workspace = self.workspace_for_file(Some(file));
773        let file_path = self.resolve_path_for_workspace(file, &workspace);
774
775        // Find symbol at position
776        let symbol = match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
777            Some(s) => s,
778            None => {
779                return Ok(ToolResult::text(format!(
780                    "No symbol found at {}:{}:{}",
781                    file, line, character
782                )));
783            }
784        };
785
786        // Find implementations via edges_to (edges pointing TO the symbol from implementations)
787        let store = self.get_store_for_workspace(&workspace)?;
788        let edges = store.edges_to(&symbol.id)?;
789        let impl_ids: Vec<String> = edges.into_iter().map(|e| e.src).collect();
790        let mut impls = store.symbols_by_ids(&impl_ids)?;
791
792        if impls.is_empty() {
793            // Fallback: search by name
794            let fallback = store.list_symbols(None, None, Some(&symbol.name), Some(limit))?;
795            if fallback.len() <= 1 {
796                return Ok(ToolResult::text(format!(
797                    "No implementations found for '{}'",
798                    symbol.name
799                )));
800            }
801            let output = format_symbols(&fallback, &workspace);
802            return Ok(ToolResult::text(format!(
803                "Implementations of '{}' (by name):\n\n{}",
804                symbol.name, output
805            )));
806        }
807
808        impls.truncate(limit);
809        let output = format_symbols(&impls, &workspace);
810        Ok(ToolResult::text(format!(
811            "Implementations of '{}':\n\n{}",
812            symbol.name, output
813        )))
814    }
815
816    fn tool_daemon_status(&mut self) -> Result<ToolResult> {
817        use crate::daemon;
818
819        let mut status = String::new();
820
821        // Show default workspace status
822        status.push_str("Default Workspace:\n");
823        if let Ok(Some(pid_info)) = daemon::read_pid_file(&self.default_workspace) {
824            if daemon::is_process_running(pid_info.pid) {
825                status.push_str(&format!(
826                    "  Daemon: running (PID {})\n  Version: {}\n  Root: {}\n  Database: {}\n",
827                    pid_info.pid,
828                    pid_info.version,
829                    self.default_workspace.display(),
830                    self.default_db_path.display()
831                ));
832            } else {
833                status.push_str(&format!(
834                    "  Daemon: not running (stale PID file)\n  Root: {}\n  Database: {}\n",
835                    self.default_workspace.display(),
836                    self.default_db_path.display()
837                ));
838            }
839        } else {
840            status.push_str(&format!(
841                "  Daemon: not running\n  Root: {}\n  Database: {}\n",
842                self.default_workspace.display(),
843                self.default_db_path.display()
844            ));
845        }
846
847        // Show cached workspaces
848        if !self.workspace_cache.is_empty() {
849            status.push_str(&format!(
850                "\nCached Workspaces ({}/{}):\n",
851                self.workspace_cache.len(),
852                MAX_CACHED_WORKSPACES
853            ));
854            for (root, info) in &self.workspace_cache {
855                let daemon_status = if let Ok(Some(pid_info)) = daemon::read_pid_file(root) {
856                    if daemon::is_process_running(pid_info.pid) {
857                        format!("running (PID {})", pid_info.pid)
858                    } else {
859                        "not running".to_string()
860                    }
861                } else {
862                    "not running".to_string()
863                };
864                let index_status = if info.store.is_some() {
865                    "loaded"
866                } else if info.db_path.exists() {
867                    "available"
868                } else {
869                    "not indexed"
870                };
871                status.push_str(&format!(
872                    "  {}\n    Daemon: {}, Index: {}\n",
873                    root.display(),
874                    daemon_status,
875                    index_status
876                ));
877            }
878        }
879
880        Ok(ToolResult::text(status))
881    }
882
883    fn tool_duplicates(&mut self, args: &Value) -> Result<ToolResult> {
884        let kind = args.get("kind").and_then(|v| v.as_str());
885        let min_count = args.get("min_count").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
886        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
887
888        // Use default workspace
889        let workspace = self.default_workspace.clone();
890        let store = self.get_store_for_workspace(&workspace)?;
891
892        let groups = store.find_duplicate_groups(min_count, kind, None)?;
893
894        if groups.is_empty() {
895            return Ok(ToolResult::text("No duplicate code found."));
896        }
897
898        let groups: Vec<_> = groups.into_iter().take(limit).collect();
899        let output = format_duplicate_groups(&groups, &workspace);
900        Ok(ToolResult::text(output))
901    }
902
903    // ==================== Helper Methods ====================
904
905    fn resolve_path_for_workspace(&self, path: &str, workspace: &Path) -> PathBuf {
906        let p = PathBuf::from(path);
907        if p.is_absolute() {
908            p
909        } else {
910            workspace.join(p)
911        }
912    }
913
914    fn find_symbol_at_in_workspace(
915        &mut self,
916        file: &Path,
917        line: usize,
918        character: usize,
919        workspace: &Path,
920    ) -> Result<Option<SymbolRecord>> {
921        let store = self.get_store_for_workspace(workspace)?;
922        let file_str = file.to_string_lossy().to_string();
923
924        // Convert line:col to byte offset
925        let content = std::fs::read_to_string(file)?;
926        let mut offset: i64 = 0;
927        for (i, l) in content.lines().enumerate() {
928            if i + 1 == line {
929                offset += character.saturating_sub(1) as i64;
930                break;
931            }
932            offset += l.len() as i64 + 1; // +1 for newline
933        }
934
935        // Find symbol containing this offset
936        let symbols = store.list_symbols(Some(&file_str), None, None, None)?;
937
938        // Find the narrowest symbol containing the offset
939        let mut best: Option<SymbolRecord> = None;
940        for sym in symbols {
941            if sym.start <= offset && offset < sym.end {
942                let span = sym.end - sym.start;
943                if best
944                    .as_ref()
945                    .map(|b| span < (b.end - b.start))
946                    .unwrap_or(true)
947                {
948                    best = Some(sym);
949                }
950            }
951        }
952
953        Ok(best)
954    }
955}
956
957// ==================== Formatting Helpers ====================
958
959/// Get relative path for a file within a workspace
960fn relative_path_for_workspace(path: &str, workspace: &Path) -> String {
961    let p = PathBuf::from(path);
962    p.strip_prefix(workspace)
963        .map(|p| p.to_string_lossy().to_string())
964        .unwrap_or_else(|_| path.to_string())
965}
966
967fn format_symbols(symbols: &[SymbolRecord], workspace_root: &Path) -> String {
968    let mut output = String::new();
969    for sym in symbols {
970        output.push_str(&format_symbol(sym, workspace_root));
971        output.push('\n');
972    }
973    output
974}
975
976fn format_symbol(sym: &SymbolRecord, workspace_root: &Path) -> String {
977    let rel_path = PathBuf::from(&sym.file)
978        .strip_prefix(workspace_root)
979        .map(|p| p.to_string_lossy().to_string())
980        .unwrap_or_else(|_| sym.file.clone());
981
982    // Convert byte offset to line:col
983    let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
984        format!("{}:{}:{}", rel_path, line, col)
985    } else {
986        format!("{}:offset:{}", rel_path, sym.start)
987    };
988
989    let mut parts = vec![format!("{:<10} {:<30} {}", sym.kind, sym.name, location)];
990
991    if let Some(ref vis) = sym.visibility {
992        parts.push(format!("  visibility: {}", vis));
993    }
994    if let Some(ref container) = sym.container {
995        parts.push(format!("  container: {}", container));
996    }
997
998    parts.join("\n")
999}
1000
1001fn format_duplicate_groups(groups: &[DuplicateGroup], workspace_root: &Path) -> String {
1002    let total_groups = groups.len();
1003    let total_duplicates: usize = groups.iter().map(|g| g.symbols.len()).sum();
1004
1005    let mut output = format!(
1006        "Found {} duplicate groups ({} total symbols)\n\n",
1007        total_groups, total_duplicates
1008    );
1009
1010    for (i, group) in groups.iter().enumerate() {
1011        let short_hash = &group.content_hash[..8.min(group.content_hash.len())];
1012        output.push_str(&format!(
1013            "Group {} ({} duplicates, hash: {}):\n",
1014            i + 1,
1015            group.symbols.len(),
1016            short_hash
1017        ));
1018
1019        for sym in &group.symbols {
1020            let rel_path = PathBuf::from(&sym.file)
1021                .strip_prefix(workspace_root)
1022                .map(|p| p.to_string_lossy().to_string())
1023                .unwrap_or_else(|_| sym.file.clone());
1024
1025            let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize)
1026            {
1027                format!("{}:{}:{}", rel_path, line, col)
1028            } else {
1029                format!("{}:offset:{}", rel_path, sym.start)
1030            };
1031
1032            let container = sym
1033                .container
1034                .as_ref()
1035                .map(|c| format!(" in {}", c))
1036                .unwrap_or_default();
1037
1038            output.push_str(&format!(
1039                "  {:<10} {:<30} {}{}\n",
1040                sym.kind, sym.name, location, container
1041            ));
1042        }
1043        output.push('\n');
1044    }
1045
1046    output
1047}
1048
1049/// Convert byte offset to 1-based line:column
1050fn offset_to_line_col(file_path: &str, offset: usize) -> Result<(usize, usize)> {
1051    let content = std::fs::read(file_path)?;
1052    let mut line = 1;
1053    let mut col = 1;
1054    for (i, &b) in content.iter().enumerate() {
1055        if i == offset {
1056            return Ok((line, col));
1057        }
1058        if b == b'\n' {
1059            line += 1;
1060            col = 1;
1061        } else {
1062            col += 1;
1063        }
1064    }
1065    if offset == content.len() {
1066        Ok((line, col))
1067    } else {
1068        anyhow::bail!("offset out of bounds")
1069    }
1070}
1071
1072/// Run the MCP server with the given workspace and database paths.
1073pub fn run_server(workspace_root: &Path, db_path: &Path) -> Result<()> {
1074    let mut server = McpServer::new(workspace_root.to_path_buf(), db_path.to_path_buf());
1075    server.run()
1076}