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) =
690            self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)?
691        {
692            let output = format_symbol(&symbol, &workspace);
693            return Ok(ToolResult::text(format!("Definition:\n{}", output)));
694        }
695
696        Ok(ToolResult::text(format!(
697            "No symbol found at {}:{}:{}",
698            file, line, character
699        )))
700    }
701
702    fn tool_usages(&mut self, args: &Value) -> Result<ToolResult> {
703        let file = args
704            .get("file")
705            .and_then(|v| v.as_str())
706            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
707        let line = args
708            .get("line")
709            .and_then(|v| v.as_u64())
710            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
711        let character = args
712            .get("character")
713            .and_then(|v| v.as_u64())
714            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
715            as usize;
716        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
717
718        // Infer workspace from file path
719        let workspace = self.workspace_for_file(Some(file));
720        let file_path = self.resolve_path_for_workspace(file, &workspace);
721
722        // Find symbol at position
723        let symbol =
724            match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
725                Some(s) => s,
726                None => {
727                    return Ok(ToolResult::text(format!(
728                        "No symbol found at {}:{}:{}",
729                        file, line, character
730                    )));
731                }
732            };
733
734        // Find references using references_for_symbol
735        let store = self.get_store_for_workspace(&workspace)?;
736        let refs = store.references_for_symbol(&symbol.id)?;
737
738        if refs.is_empty() {
739            return Ok(ToolResult::text(format!(
740                "No usages found for '{}'",
741                symbol.name
742            )));
743        }
744
745        let refs: Vec<_> = refs.into_iter().take(limit).collect();
746        let mut output = format!("Usages of '{}' ({} found):\n\n", symbol.name, refs.len());
747        for r in &refs {
748            let rel_path = relative_path_for_workspace(&r.file, &workspace);
749            // Convert offset to line:col
750            if let Ok((ref_line, ref_col)) = offset_to_line_col(&r.file, r.start as usize) {
751                output.push_str(&format!("  {}:{}:{}\n", rel_path, ref_line, ref_col));
752            }
753        }
754
755        Ok(ToolResult::text(output))
756    }
757
758    fn tool_implementations(&mut self, args: &Value) -> Result<ToolResult> {
759        let file = args
760            .get("file")
761            .and_then(|v| v.as_str())
762            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
763        let line = args
764            .get("line")
765            .and_then(|v| v.as_u64())
766            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
767        let character = args
768            .get("character")
769            .and_then(|v| v.as_u64())
770            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
771            as usize;
772        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
773
774        // Infer workspace from file path
775        let workspace = self.workspace_for_file(Some(file));
776        let file_path = self.resolve_path_for_workspace(file, &workspace);
777
778        // Find symbol at position
779        let symbol =
780            match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
781                Some(s) => s,
782                None => {
783                    return Ok(ToolResult::text(format!(
784                        "No symbol found at {}:{}:{}",
785                        file, line, character
786                    )));
787                }
788            };
789
790        // Find implementations via edges_to (edges pointing TO the symbol from implementations)
791        let store = self.get_store_for_workspace(&workspace)?;
792        let edges = store.edges_to(&symbol.id)?;
793        let impl_ids: Vec<String> = edges.into_iter().map(|e| e.src).collect();
794        let mut impls = store.symbols_by_ids(&impl_ids)?;
795
796        if impls.is_empty() {
797            // Fallback: search by name
798            let fallback = store.list_symbols(None, None, Some(&symbol.name), Some(limit))?;
799            if fallback.len() <= 1 {
800                return Ok(ToolResult::text(format!(
801                    "No implementations found for '{}'",
802                    symbol.name
803                )));
804            }
805            let output = format_symbols(&fallback, &workspace);
806            return Ok(ToolResult::text(format!(
807                "Implementations of '{}' (by name):\n\n{}",
808                symbol.name, output
809            )));
810        }
811
812        impls.truncate(limit);
813        let output = format_symbols(&impls, &workspace);
814        Ok(ToolResult::text(format!(
815            "Implementations of '{}':\n\n{}",
816            symbol.name, output
817        )))
818    }
819
820    fn tool_daemon_status(&mut self) -> Result<ToolResult> {
821        use crate::daemon;
822
823        let mut status = String::new();
824
825        // Show default workspace status
826        status.push_str("Default Workspace:\n");
827        if let Ok(Some(pid_info)) = daemon::read_pid_file(&self.default_workspace) {
828            if daemon::is_process_running(pid_info.pid) {
829                status.push_str(&format!(
830                    "  Daemon: running (PID {})\n  Version: {}\n  Root: {}\n  Database: {}\n",
831                    pid_info.pid,
832                    pid_info.version,
833                    self.default_workspace.display(),
834                    self.default_db_path.display()
835                ));
836            } else {
837                status.push_str(&format!(
838                    "  Daemon: not running (stale PID file)\n  Root: {}\n  Database: {}\n",
839                    self.default_workspace.display(),
840                    self.default_db_path.display()
841                ));
842            }
843        } else {
844            status.push_str(&format!(
845                "  Daemon: not running\n  Root: {}\n  Database: {}\n",
846                self.default_workspace.display(),
847                self.default_db_path.display()
848            ));
849        }
850
851        // Show cached workspaces
852        if !self.workspace_cache.is_empty() {
853            status.push_str(&format!(
854                "\nCached Workspaces ({}/{}):\n",
855                self.workspace_cache.len(),
856                MAX_CACHED_WORKSPACES
857            ));
858            for (root, info) in &self.workspace_cache {
859                let daemon_status = if let Ok(Some(pid_info)) = daemon::read_pid_file(root) {
860                    if daemon::is_process_running(pid_info.pid) {
861                        format!("running (PID {})", pid_info.pid)
862                    } else {
863                        "not running".to_string()
864                    }
865                } else {
866                    "not running".to_string()
867                };
868                let index_status = if info.store.is_some() {
869                    "loaded"
870                } else if info.db_path.exists() {
871                    "available"
872                } else {
873                    "not indexed"
874                };
875                status.push_str(&format!(
876                    "  {}\n    Daemon: {}, Index: {}\n",
877                    root.display(),
878                    daemon_status,
879                    index_status
880                ));
881            }
882        }
883
884        Ok(ToolResult::text(status))
885    }
886
887    fn tool_duplicates(&mut self, args: &Value) -> Result<ToolResult> {
888        let kind = args.get("kind").and_then(|v| v.as_str());
889        let min_count = args.get("min_count").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
890        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
891
892        // Use default workspace
893        let workspace = self.default_workspace.clone();
894        let store = self.get_store_for_workspace(&workspace)?;
895
896        let groups = store.find_duplicate_groups(min_count, kind, None)?;
897
898        if groups.is_empty() {
899            return Ok(ToolResult::text("No duplicate code found."));
900        }
901
902        let groups: Vec<_> = groups.into_iter().take(limit).collect();
903        let output = format_duplicate_groups(&groups, &workspace);
904        Ok(ToolResult::text(output))
905    }
906
907    // ==================== Helper Methods ====================
908
909    fn resolve_path_for_workspace(&self, path: &str, workspace: &Path) -> PathBuf {
910        let p = PathBuf::from(path);
911        if p.is_absolute() {
912            p
913        } else {
914            workspace.join(p)
915        }
916    }
917
918    fn find_symbol_at_in_workspace(
919        &mut self,
920        file: &Path,
921        line: usize,
922        character: usize,
923        workspace: &Path,
924    ) -> Result<Option<SymbolRecord>> {
925        let store = self.get_store_for_workspace(workspace)?;
926        let file_str = file.to_string_lossy().to_string();
927
928        // Convert line:col to byte offset
929        let content = std::fs::read_to_string(file)?;
930        let mut offset: i64 = 0;
931        for (i, l) in content.lines().enumerate() {
932            if i + 1 == line {
933                offset += character.saturating_sub(1) as i64;
934                break;
935            }
936            offset += l.len() as i64 + 1; // +1 for newline
937        }
938
939        // Find symbol containing this offset
940        let symbols = store.list_symbols(Some(&file_str), None, None, None)?;
941
942        // Find the narrowest symbol containing the offset
943        let mut best: Option<SymbolRecord> = None;
944        for sym in symbols {
945            if sym.start <= offset && offset < sym.end {
946                let span = sym.end - sym.start;
947                if best
948                    .as_ref()
949                    .map(|b| span < (b.end - b.start))
950                    .unwrap_or(true)
951                {
952                    best = Some(sym);
953                }
954            }
955        }
956
957        Ok(best)
958    }
959}
960
961// ==================== Formatting Helpers ====================
962
963/// Get relative path for a file within a workspace
964fn relative_path_for_workspace(path: &str, workspace: &Path) -> String {
965    let p = PathBuf::from(path);
966    p.strip_prefix(workspace)
967        .map(|p| p.to_string_lossy().to_string())
968        .unwrap_or_else(|_| path.to_string())
969}
970
971fn format_symbols(symbols: &[SymbolRecord], workspace_root: &Path) -> String {
972    let mut output = String::new();
973    for sym in symbols {
974        output.push_str(&format_symbol(sym, workspace_root));
975        output.push('\n');
976    }
977    output
978}
979
980fn format_symbol(sym: &SymbolRecord, workspace_root: &Path) -> String {
981    let rel_path = PathBuf::from(&sym.file)
982        .strip_prefix(workspace_root)
983        .map(|p| p.to_string_lossy().to_string())
984        .unwrap_or_else(|_| sym.file.clone());
985
986    // Convert byte offset to line:col
987    let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
988        format!("{}:{}:{}", rel_path, line, col)
989    } else {
990        format!("{}:offset:{}", rel_path, sym.start)
991    };
992
993    let mut parts = vec![format!("{:<10} {:<30} {}", sym.kind, sym.name, location)];
994
995    if let Some(ref vis) = sym.visibility {
996        parts.push(format!("  visibility: {}", vis));
997    }
998    if let Some(ref container) = sym.container {
999        parts.push(format!("  container: {}", container));
1000    }
1001
1002    parts.join("\n")
1003}
1004
1005fn format_duplicate_groups(groups: &[DuplicateGroup], workspace_root: &Path) -> String {
1006    let total_groups = groups.len();
1007    let total_duplicates: usize = groups.iter().map(|g| g.symbols.len()).sum();
1008
1009    let mut output = format!(
1010        "Found {} duplicate groups ({} total symbols)\n\n",
1011        total_groups, total_duplicates
1012    );
1013
1014    for (i, group) in groups.iter().enumerate() {
1015        let short_hash = &group.content_hash[..8.min(group.content_hash.len())];
1016        output.push_str(&format!(
1017            "Group {} ({} duplicates, hash: {}):\n",
1018            i + 1,
1019            group.symbols.len(),
1020            short_hash
1021        ));
1022
1023        for sym in &group.symbols {
1024            let rel_path = PathBuf::from(&sym.file)
1025                .strip_prefix(workspace_root)
1026                .map(|p| p.to_string_lossy().to_string())
1027                .unwrap_or_else(|_| sym.file.clone());
1028
1029            let location =
1030                if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
1031                    format!("{}:{}:{}", rel_path, line, col)
1032                } else {
1033                    format!("{}:offset:{}", rel_path, sym.start)
1034                };
1035
1036            let container = sym
1037                .container
1038                .as_ref()
1039                .map(|c| format!(" in {}", c))
1040                .unwrap_or_default();
1041
1042            output.push_str(&format!(
1043                "  {:<10} {:<30} {}{}\n",
1044                sym.kind, sym.name, location, container
1045            ));
1046        }
1047        output.push('\n');
1048    }
1049
1050    output
1051}
1052
1053/// Convert byte offset to 1-based line:column
1054fn offset_to_line_col(file_path: &str, offset: usize) -> Result<(usize, usize)> {
1055    let content = std::fs::read(file_path)?;
1056    let mut line = 1;
1057    let mut col = 1;
1058    for (i, &b) in content.iter().enumerate() {
1059        if i == offset {
1060            return Ok((line, col));
1061        }
1062        if b == b'\n' {
1063            line += 1;
1064            col = 1;
1065        } else {
1066            col += 1;
1067        }
1068    }
1069    if offset == content.len() {
1070        Ok((line, col))
1071    } else {
1072        anyhow::bail!("offset out of bounds")
1073    }
1074}
1075
1076/// Run the MCP server with the given workspace and database paths.
1077pub fn run_server(workspace_root: &Path, db_path: &Path) -> Result<()> {
1078    let mut server = McpServer::new(workspace_root.to_path_buf(), db_path.to_path_buf());
1079    server.run()
1080}