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::{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        ];
432
433        Ok(json!({ "tools": tools }))
434    }
435
436    fn handle_tools_call(&mut self, params: &Value) -> Result<Value> {
437        let name = params
438            .get("name")
439            .and_then(|v| v.as_str())
440            .ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
441
442        let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
443
444        let result = match name {
445            "gabb_symbols" => self.tool_symbols(&arguments),
446            "gabb_symbol" => self.tool_symbol(&arguments),
447            "gabb_definition" => self.tool_definition(&arguments),
448            "gabb_usages" => self.tool_usages(&arguments),
449            "gabb_implementations" => self.tool_implementations(&arguments),
450            "gabb_daemon_status" => self.tool_daemon_status(),
451            _ => Ok(ToolResult::error(format!("Unknown tool: {}", name))),
452        }?;
453
454        Ok(serde_json::to_value(result)?)
455    }
456
457    // ==================== Workspace Management ====================
458
459    /// Infer workspace root from a file path by walking up to find markers
460    fn infer_workspace(&self, file_path: &Path) -> Option<PathBuf> {
461        let file_path = if file_path.is_absolute() {
462            file_path.to_path_buf()
463        } else {
464            self.default_workspace.join(file_path)
465        };
466
467        let mut current = file_path.parent()?;
468
469        loop {
470            // Check file markers
471            for marker in WORKSPACE_MARKERS {
472                if current.join(marker).exists() {
473                    return Some(current.to_path_buf());
474                }
475            }
476
477            // Check directory markers
478            for marker in WORKSPACE_DIR_MARKERS {
479                let marker_path = current.join(marker);
480                if marker_path.is_dir() {
481                    return Some(current.to_path_buf());
482                }
483            }
484
485            // Move up to parent directory
486            current = current.parent()?;
487        }
488    }
489
490    /// Get or create workspace info for a given workspace root
491    fn get_or_create_workspace(&mut self, workspace_root: &Path) -> Result<&mut WorkspaceInfo> {
492        let workspace_root = workspace_root
493            .canonicalize()
494            .unwrap_or_else(|_| workspace_root.to_path_buf());
495
496        // Evict oldest workspace if cache is full
497        if !self.workspace_cache.contains_key(&workspace_root)
498            && self.workspace_cache.len() >= MAX_CACHED_WORKSPACES
499        {
500            // Find oldest entry
501            if let Some(oldest_key) = self
502                .workspace_cache
503                .iter()
504                .min_by_key(|(_, info)| info.last_used)
505                .map(|(k, _)| k.clone())
506            {
507                log::debug!("Evicting workspace from cache: {}", oldest_key.display());
508                self.workspace_cache.remove(&oldest_key);
509            }
510        }
511
512        // Insert if not present
513        if !self.workspace_cache.contains_key(&workspace_root) {
514            let db_path = workspace_root.join(".gabb/index.db");
515            log::debug!(
516                "Adding workspace to cache: {} (db: {})",
517                workspace_root.display(),
518                db_path.display()
519            );
520            self.workspace_cache.insert(
521                workspace_root.clone(),
522                WorkspaceInfo {
523                    root: workspace_root.clone(),
524                    db_path,
525                    store: None,
526                    last_used: std::time::Instant::now(),
527                },
528            );
529        }
530
531        // Update last_used and return
532        let info = self.workspace_cache.get_mut(&workspace_root).unwrap();
533        info.last_used = std::time::Instant::now();
534        Ok(info)
535    }
536
537    /// Ensure index exists for a workspace, starting daemon if needed
538    fn ensure_workspace_index(&mut self, workspace_root: &Path) -> Result<()> {
539        use crate::daemon;
540
541        let info = self.get_or_create_workspace(workspace_root)?;
542
543        if info.store.is_some() {
544            return Ok(());
545        }
546
547        if !info.db_path.exists() {
548            // Start daemon in background
549            log::info!(
550                "Index not found for {}. Starting daemon...",
551                info.root.display()
552            );
553            daemon::start(&info.root, &info.db_path, false, true, None)?;
554
555            // Wait for index to be ready
556            let max_wait = std::time::Duration::from_secs(60);
557            let start = std::time::Instant::now();
558            let db_path = info.db_path.clone();
559            while !db_path.exists() && start.elapsed() < max_wait {
560                std::thread::sleep(std::time::Duration::from_millis(500));
561            }
562
563            if !db_path.exists() {
564                bail!("Daemon started but index not created within 60 seconds");
565            }
566        }
567
568        // Re-get info (borrow checker)
569        let info = self.workspace_cache.get_mut(workspace_root).unwrap();
570        info.store = Some(IndexStore::open(&info.db_path)?);
571        Ok(())
572    }
573
574    /// Get workspace root for a file, falling back to default
575    fn workspace_for_file(&self, file_path: Option<&str>) -> PathBuf {
576        if let Some(path) = file_path {
577            let path = PathBuf::from(path);
578            if let Some(workspace) = self.infer_workspace(&path) {
579                return workspace;
580            }
581        }
582        self.default_workspace.clone()
583    }
584
585    /// Get store for a workspace (ensures index exists)
586    fn get_store_for_workspace(&mut self, workspace_root: &Path) -> Result<&IndexStore> {
587        self.ensure_workspace_index(workspace_root)?;
588        let info = self.workspace_cache.get(workspace_root).unwrap();
589        info.store
590            .as_ref()
591            .ok_or_else(|| anyhow::anyhow!("Store not initialized"))
592    }
593
594    // ==================== Tool Implementations ====================
595
596    fn tool_symbols(&mut self, args: &Value) -> Result<ToolResult> {
597        let name = args.get("name").and_then(|v| v.as_str());
598        let kind = args.get("kind").and_then(|v| v.as_str());
599        let file = args.get("file").and_then(|v| v.as_str());
600        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
601
602        // Infer workspace from file path if provided
603        let workspace = self.workspace_for_file(file);
604        let store = self.get_store_for_workspace(&workspace)?;
605
606        let symbols = store.list_symbols(file, kind, name, Some(limit))?;
607
608        if symbols.is_empty() {
609            return Ok(ToolResult::text("No symbols found matching the criteria."));
610        }
611
612        let output = format_symbols(&symbols, &workspace);
613        Ok(ToolResult::text(output))
614    }
615
616    fn tool_symbol(&mut self, args: &Value) -> Result<ToolResult> {
617        // Use default workspace since we don't have a file path
618        let workspace = self.default_workspace.clone();
619        let store = self.get_store_for_workspace(&workspace)?;
620
621        let name = args
622            .get("name")
623            .and_then(|v| v.as_str())
624            .ok_or_else(|| anyhow::anyhow!("Missing 'name' argument"))?;
625
626        let kind = args.get("kind").and_then(|v| v.as_str());
627
628        let symbols = store.list_symbols(None, kind, Some(name), Some(10))?;
629
630        if symbols.is_empty() {
631            return Ok(ToolResult::text(format!(
632                "No symbol found with name '{}'",
633                name
634            )));
635        }
636
637        let output = format_symbols(&symbols, &workspace);
638        Ok(ToolResult::text(output))
639    }
640
641    fn tool_definition(&mut self, args: &Value) -> Result<ToolResult> {
642        let file = args
643            .get("file")
644            .and_then(|v| v.as_str())
645            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
646        let line = args
647            .get("line")
648            .and_then(|v| v.as_u64())
649            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
650        let character = args
651            .get("character")
652            .and_then(|v| v.as_u64())
653            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
654            as usize;
655
656        // Infer workspace from file path
657        let workspace = self.workspace_for_file(Some(file));
658        let file_path = self.resolve_path_for_workspace(file, &workspace);
659
660        // Find symbol at position
661        if let Some(symbol) = self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
662            let output = format_symbol(&symbol, &workspace);
663            return Ok(ToolResult::text(format!("Definition:\n{}", output)));
664        }
665
666        Ok(ToolResult::text(format!(
667            "No symbol found at {}:{}:{}",
668            file, line, character
669        )))
670    }
671
672    fn tool_usages(&mut self, args: &Value) -> Result<ToolResult> {
673        let file = args
674            .get("file")
675            .and_then(|v| v.as_str())
676            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
677        let line = args
678            .get("line")
679            .and_then(|v| v.as_u64())
680            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
681        let character = args
682            .get("character")
683            .and_then(|v| v.as_u64())
684            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
685            as usize;
686        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
687
688        // Infer workspace from file path
689        let workspace = self.workspace_for_file(Some(file));
690        let file_path = self.resolve_path_for_workspace(file, &workspace);
691
692        // Find symbol at position
693        let symbol = match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
694            Some(s) => s,
695            None => {
696                return Ok(ToolResult::text(format!(
697                    "No symbol found at {}:{}:{}",
698                    file, line, character
699                )));
700            }
701        };
702
703        // Find references using references_for_symbol
704        let store = self.get_store_for_workspace(&workspace)?;
705        let refs = store.references_for_symbol(&symbol.id)?;
706
707        if refs.is_empty() {
708            return Ok(ToolResult::text(format!(
709                "No usages found for '{}'",
710                symbol.name
711            )));
712        }
713
714        let refs: Vec<_> = refs.into_iter().take(limit).collect();
715        let mut output = format!("Usages of '{}' ({} found):\n\n", symbol.name, refs.len());
716        for r in &refs {
717            let rel_path = relative_path_for_workspace(&r.file, &workspace);
718            // Convert offset to line:col
719            if let Ok((ref_line, ref_col)) = offset_to_line_col(&r.file, r.start as usize) {
720                output.push_str(&format!("  {}:{}:{}\n", rel_path, ref_line, ref_col));
721            }
722        }
723
724        Ok(ToolResult::text(output))
725    }
726
727    fn tool_implementations(&mut self, args: &Value) -> Result<ToolResult> {
728        let file = args
729            .get("file")
730            .and_then(|v| v.as_str())
731            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
732        let line = args
733            .get("line")
734            .and_then(|v| v.as_u64())
735            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
736        let character = args
737            .get("character")
738            .and_then(|v| v.as_u64())
739            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
740            as usize;
741        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
742
743        // Infer workspace from file path
744        let workspace = self.workspace_for_file(Some(file));
745        let file_path = self.resolve_path_for_workspace(file, &workspace);
746
747        // Find symbol at position
748        let symbol = match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
749            Some(s) => s,
750            None => {
751                return Ok(ToolResult::text(format!(
752                    "No symbol found at {}:{}:{}",
753                    file, line, character
754                )));
755            }
756        };
757
758        // Find implementations via edges_to (edges pointing TO the symbol from implementations)
759        let store = self.get_store_for_workspace(&workspace)?;
760        let edges = store.edges_to(&symbol.id)?;
761        let impl_ids: Vec<String> = edges.into_iter().map(|e| e.src).collect();
762        let mut impls = store.symbols_by_ids(&impl_ids)?;
763
764        if impls.is_empty() {
765            // Fallback: search by name
766            let fallback = store.list_symbols(None, None, Some(&symbol.name), Some(limit))?;
767            if fallback.len() <= 1 {
768                return Ok(ToolResult::text(format!(
769                    "No implementations found for '{}'",
770                    symbol.name
771                )));
772            }
773            let output = format_symbols(&fallback, &workspace);
774            return Ok(ToolResult::text(format!(
775                "Implementations of '{}' (by name):\n\n{}",
776                symbol.name, output
777            )));
778        }
779
780        impls.truncate(limit);
781        let output = format_symbols(&impls, &workspace);
782        Ok(ToolResult::text(format!(
783            "Implementations of '{}':\n\n{}",
784            symbol.name, output
785        )))
786    }
787
788    fn tool_daemon_status(&mut self) -> Result<ToolResult> {
789        use crate::daemon;
790
791        let mut status = String::new();
792
793        // Show default workspace status
794        status.push_str("Default Workspace:\n");
795        if let Ok(Some(pid_info)) = daemon::read_pid_file(&self.default_workspace) {
796            if daemon::is_process_running(pid_info.pid) {
797                status.push_str(&format!(
798                    "  Daemon: running (PID {})\n  Version: {}\n  Root: {}\n  Database: {}\n",
799                    pid_info.pid,
800                    pid_info.version,
801                    self.default_workspace.display(),
802                    self.default_db_path.display()
803                ));
804            } else {
805                status.push_str(&format!(
806                    "  Daemon: not running (stale PID file)\n  Root: {}\n  Database: {}\n",
807                    self.default_workspace.display(),
808                    self.default_db_path.display()
809                ));
810            }
811        } else {
812            status.push_str(&format!(
813                "  Daemon: not running\n  Root: {}\n  Database: {}\n",
814                self.default_workspace.display(),
815                self.default_db_path.display()
816            ));
817        }
818
819        // Show cached workspaces
820        if !self.workspace_cache.is_empty() {
821            status.push_str(&format!(
822                "\nCached Workspaces ({}/{}):\n",
823                self.workspace_cache.len(),
824                MAX_CACHED_WORKSPACES
825            ));
826            for (root, info) in &self.workspace_cache {
827                let daemon_status = if let Ok(Some(pid_info)) = daemon::read_pid_file(root) {
828                    if daemon::is_process_running(pid_info.pid) {
829                        format!("running (PID {})", pid_info.pid)
830                    } else {
831                        "not running".to_string()
832                    }
833                } else {
834                    "not running".to_string()
835                };
836                let index_status = if info.store.is_some() {
837                    "loaded"
838                } else if info.db_path.exists() {
839                    "available"
840                } else {
841                    "not indexed"
842                };
843                status.push_str(&format!(
844                    "  {}\n    Daemon: {}, Index: {}\n",
845                    root.display(),
846                    daemon_status,
847                    index_status
848                ));
849            }
850        }
851
852        Ok(ToolResult::text(status))
853    }
854
855    // ==================== Helper Methods ====================
856
857    fn resolve_path_for_workspace(&self, path: &str, workspace: &Path) -> PathBuf {
858        let p = PathBuf::from(path);
859        if p.is_absolute() {
860            p
861        } else {
862            workspace.join(p)
863        }
864    }
865
866    fn find_symbol_at_in_workspace(
867        &mut self,
868        file: &Path,
869        line: usize,
870        character: usize,
871        workspace: &Path,
872    ) -> Result<Option<SymbolRecord>> {
873        let store = self.get_store_for_workspace(workspace)?;
874        let file_str = file.to_string_lossy().to_string();
875
876        // Convert line:col to byte offset
877        let content = std::fs::read_to_string(file)?;
878        let mut offset: i64 = 0;
879        for (i, l) in content.lines().enumerate() {
880            if i + 1 == line {
881                offset += character.saturating_sub(1) as i64;
882                break;
883            }
884            offset += l.len() as i64 + 1; // +1 for newline
885        }
886
887        // Find symbol containing this offset
888        let symbols = store.list_symbols(Some(&file_str), None, None, None)?;
889
890        // Find the narrowest symbol containing the offset
891        let mut best: Option<SymbolRecord> = None;
892        for sym in symbols {
893            if sym.start <= offset && offset < sym.end {
894                let span = sym.end - sym.start;
895                if best
896                    .as_ref()
897                    .map(|b| span < (b.end - b.start))
898                    .unwrap_or(true)
899                {
900                    best = Some(sym);
901                }
902            }
903        }
904
905        Ok(best)
906    }
907}
908
909// ==================== Formatting Helpers ====================
910
911/// Get relative path for a file within a workspace
912fn relative_path_for_workspace(path: &str, workspace: &Path) -> String {
913    let p = PathBuf::from(path);
914    p.strip_prefix(workspace)
915        .map(|p| p.to_string_lossy().to_string())
916        .unwrap_or_else(|_| path.to_string())
917}
918
919fn format_symbols(symbols: &[SymbolRecord], workspace_root: &Path) -> String {
920    let mut output = String::new();
921    for sym in symbols {
922        output.push_str(&format_symbol(sym, workspace_root));
923        output.push('\n');
924    }
925    output
926}
927
928fn format_symbol(sym: &SymbolRecord, workspace_root: &Path) -> String {
929    let rel_path = PathBuf::from(&sym.file)
930        .strip_prefix(workspace_root)
931        .map(|p| p.to_string_lossy().to_string())
932        .unwrap_or_else(|_| sym.file.clone());
933
934    // Convert byte offset to line:col
935    let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
936        format!("{}:{}:{}", rel_path, line, col)
937    } else {
938        format!("{}:offset:{}", rel_path, sym.start)
939    };
940
941    let mut parts = vec![format!("{:<10} {:<30} {}", sym.kind, sym.name, location)];
942
943    if let Some(ref vis) = sym.visibility {
944        parts.push(format!("  visibility: {}", vis));
945    }
946    if let Some(ref container) = sym.container {
947        parts.push(format!("  container: {}", container));
948    }
949
950    parts.join("\n")
951}
952
953/// Convert byte offset to 1-based line:column
954fn offset_to_line_col(file_path: &str, offset: usize) -> Result<(usize, usize)> {
955    let content = std::fs::read(file_path)?;
956    let mut line = 1;
957    let mut col = 1;
958    for (i, &b) in content.iter().enumerate() {
959        if i == offset {
960            return Ok((line, col));
961        }
962        if b == b'\n' {
963            line += 1;
964            col = 1;
965        } else {
966            col += 1;
967        }
968    }
969    if offset == content.len() {
970        Ok((line, col))
971    } else {
972        anyhow::bail!("offset out of bounds")
973    }
974}
975
976/// Run the MCP server with the given workspace and database paths.
977pub fn run_server(workspace_root: &Path, db_path: &Path) -> Result<()> {
978    let mut server = McpServer::new(workspace_root.to_path_buf(), db_path.to_path_buf());
979    server.run()
980}