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
6use crate::store::{IndexStore, SymbolRecord};
7use anyhow::{bail, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use std::io::{self, BufRead, Write};
11use std::path::{Path, PathBuf};
12
13/// MCP Protocol version
14const PROTOCOL_VERSION: &str = "2024-11-05";
15
16/// Server info
17const SERVER_NAME: &str = "gabb";
18const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20// ==================== JSON-RPC Types ====================
21
22#[derive(Debug, Deserialize)]
23#[allow(dead_code)]
24struct JsonRpcRequest {
25    jsonrpc: String,
26    id: Option<Value>,
27    method: String,
28    #[serde(default)]
29    params: Value,
30}
31
32#[derive(Debug, Serialize)]
33struct JsonRpcResponse {
34    jsonrpc: String,
35    id: Value,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    result: Option<Value>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    error: Option<JsonRpcError>,
40}
41
42#[derive(Debug, Serialize)]
43struct JsonRpcError {
44    code: i32,
45    message: String,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    data: Option<Value>,
48}
49
50impl JsonRpcResponse {
51    fn success(id: Value, result: Value) -> Self {
52        Self {
53            jsonrpc: "2.0".to_string(),
54            id,
55            result: Some(result),
56            error: None,
57        }
58    }
59
60    fn error(id: Value, code: i32, message: impl Into<String>) -> Self {
61        Self {
62            jsonrpc: "2.0".to_string(),
63            id,
64            result: None,
65            error: Some(JsonRpcError {
66                code,
67                message: message.into(),
68                data: None,
69            }),
70        }
71    }
72}
73
74// JSON-RPC error codes
75const PARSE_ERROR: i32 = -32700;
76const INTERNAL_ERROR: i32 = -32603;
77
78// ==================== MCP Types ====================
79
80#[derive(Debug, Serialize)]
81struct Tool {
82    name: String,
83    description: String,
84    #[serde(rename = "inputSchema")]
85    input_schema: Value,
86}
87
88#[derive(Debug, Serialize)]
89struct ToolResult {
90    content: Vec<ToolContent>,
91    #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
92    is_error: Option<bool>,
93}
94
95#[derive(Debug, Serialize)]
96struct ToolContent {
97    #[serde(rename = "type")]
98    content_type: String,
99    text: String,
100}
101
102impl ToolResult {
103    fn text(text: impl Into<String>) -> Self {
104        Self {
105            content: vec![ToolContent {
106                content_type: "text".to_string(),
107                text: text.into(),
108            }],
109            is_error: None,
110        }
111    }
112
113    fn error(message: impl Into<String>) -> Self {
114        Self {
115            content: vec![ToolContent {
116                content_type: "text".to_string(),
117                text: message.into(),
118            }],
119            is_error: Some(true),
120        }
121    }
122}
123
124// ==================== MCP Server ====================
125
126/// MCP Server state
127pub struct McpServer {
128    workspace_root: PathBuf,
129    db_path: PathBuf,
130    store: Option<IndexStore>,
131    initialized: bool,
132}
133
134impl McpServer {
135    pub fn new(workspace_root: PathBuf, db_path: PathBuf) -> Self {
136        Self {
137            workspace_root,
138            db_path,
139            store: None,
140            initialized: false,
141        }
142    }
143
144    /// Run the MCP server, reading from stdin and writing to stdout.
145    pub fn run(&mut self) -> Result<()> {
146        let stdin = io::stdin();
147        let mut stdout = io::stdout();
148
149        for line in stdin.lock().lines() {
150            let line = line?;
151            if line.is_empty() {
152                continue;
153            }
154
155            let response = self.handle_message(&line);
156            if let Some(response) = response {
157                let json = serde_json::to_string(&response)?;
158                writeln!(stdout, "{}", json)?;
159                stdout.flush()?;
160            }
161        }
162
163        Ok(())
164    }
165
166    fn handle_message(&mut self, line: &str) -> Option<JsonRpcResponse> {
167        // Parse the JSON-RPC request
168        let request: JsonRpcRequest = match serde_json::from_str(line) {
169            Ok(req) => req,
170            Err(e) => {
171                return Some(JsonRpcResponse::error(
172                    Value::Null,
173                    PARSE_ERROR,
174                    format!("Parse error: {}", e),
175                ));
176            }
177        };
178
179        // Notifications don't get responses
180        let id = match request.id {
181            Some(id) => id,
182            None => {
183                // Handle notification (no response needed)
184                self.handle_notification(&request.method, &request.params);
185                return None;
186            }
187        };
188
189        // Handle the request
190        match self.handle_request(&request.method, &request.params) {
191            Ok(result) => Some(JsonRpcResponse::success(id, result)),
192            Err(e) => Some(JsonRpcResponse::error(id, INTERNAL_ERROR, e.to_string())),
193        }
194    }
195
196    fn handle_notification(&mut self, method: &str, _params: &Value) {
197        match method {
198            "notifications/initialized" => {
199                self.initialized = true;
200                log::info!("MCP client initialized");
201            }
202            _ => {
203                log::debug!("Unknown notification: {}", method);
204            }
205        }
206    }
207
208    fn handle_request(&mut self, method: &str, params: &Value) -> Result<Value> {
209        match method {
210            "initialize" => self.handle_initialize(params),
211            "tools/list" => self.handle_tools_list(),
212            "tools/call" => self.handle_tools_call(params),
213            _ => bail!("Method not found: {}", method),
214        }
215    }
216
217    fn handle_initialize(&mut self, _params: &Value) -> Result<Value> {
218        // Ensure index is available (auto-start daemon if needed)
219        self.ensure_index()?;
220
221        Ok(json!({
222            "protocolVersion": PROTOCOL_VERSION,
223            "capabilities": {
224                "tools": {
225                    "listChanged": false
226                }
227            },
228            "serverInfo": {
229                "name": SERVER_NAME,
230                "version": SERVER_VERSION
231            }
232        }))
233    }
234
235    fn handle_tools_list(&self) -> Result<Value> {
236        let tools = vec![
237            Tool {
238                name: "gabb_symbols".to_string(),
239                description: "List or search symbols in the codebase. Returns functions, classes, interfaces, types, etc.".to_string(),
240                input_schema: json!({
241                    "type": "object",
242                    "properties": {
243                        "name": {
244                            "type": "string",
245                            "description": "Filter by symbol name (exact match)"
246                        },
247                        "kind": {
248                            "type": "string",
249                            "description": "Filter by kind (function, class, interface, type, etc.)"
250                        },
251                        "file": {
252                            "type": "string",
253                            "description": "Filter by file path"
254                        },
255                        "limit": {
256                            "type": "integer",
257                            "description": "Maximum number of results (default: 50)"
258                        }
259                    }
260                }),
261            },
262            Tool {
263                name: "gabb_symbol".to_string(),
264                description: "Get detailed information about a symbol by name.".to_string(),
265                input_schema: json!({
266                    "type": "object",
267                    "properties": {
268                        "name": {
269                            "type": "string",
270                            "description": "Symbol name to find"
271                        },
272                        "kind": {
273                            "type": "string",
274                            "description": "Filter by kind (function, class, interface, type, etc.)"
275                        }
276                    },
277                    "required": ["name"]
278                }),
279            },
280            Tool {
281                name: "gabb_definition".to_string(),
282                description: "Go to definition for a symbol at a source position.".to_string(),
283                input_schema: json!({
284                    "type": "object",
285                    "properties": {
286                        "file": {
287                            "type": "string",
288                            "description": "Source file path"
289                        },
290                        "line": {
291                            "type": "integer",
292                            "description": "1-based line number"
293                        },
294                        "character": {
295                            "type": "integer",
296                            "description": "1-based column number"
297                        }
298                    },
299                    "required": ["file", "line", "character"]
300                }),
301            },
302            Tool {
303                name: "gabb_usages".to_string(),
304                description: "Find all usages/references of a symbol at a source position.".to_string(),
305                input_schema: json!({
306                    "type": "object",
307                    "properties": {
308                        "file": {
309                            "type": "string",
310                            "description": "Source file path"
311                        },
312                        "line": {
313                            "type": "integer",
314                            "description": "1-based line number"
315                        },
316                        "character": {
317                            "type": "integer",
318                            "description": "1-based column number"
319                        },
320                        "limit": {
321                            "type": "integer",
322                            "description": "Maximum number of results (default: 50)"
323                        }
324                    },
325                    "required": ["file", "line", "character"]
326                }),
327            },
328            Tool {
329                name: "gabb_implementations".to_string(),
330                description: "Find implementations of an interface, trait, or abstract class.".to_string(),
331                input_schema: json!({
332                    "type": "object",
333                    "properties": {
334                        "file": {
335                            "type": "string",
336                            "description": "Source file path"
337                        },
338                        "line": {
339                            "type": "integer",
340                            "description": "1-based line number"
341                        },
342                        "character": {
343                            "type": "integer",
344                            "description": "1-based column number"
345                        },
346                        "limit": {
347                            "type": "integer",
348                            "description": "Maximum number of results (default: 50)"
349                        }
350                    },
351                    "required": ["file", "line", "character"]
352                }),
353            },
354            Tool {
355                name: "gabb_daemon_status".to_string(),
356                description: "Check the status of the gabb indexing daemon.".to_string(),
357                input_schema: json!({
358                    "type": "object",
359                    "properties": {}
360                }),
361            },
362        ];
363
364        Ok(json!({ "tools": tools }))
365    }
366
367    fn handle_tools_call(&mut self, params: &Value) -> Result<Value> {
368        let name = params
369            .get("name")
370            .and_then(|v| v.as_str())
371            .ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
372
373        let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
374
375        let result = match name {
376            "gabb_symbols" => self.tool_symbols(&arguments),
377            "gabb_symbol" => self.tool_symbol(&arguments),
378            "gabb_definition" => self.tool_definition(&arguments),
379            "gabb_usages" => self.tool_usages(&arguments),
380            "gabb_implementations" => self.tool_implementations(&arguments),
381            "gabb_daemon_status" => self.tool_daemon_status(),
382            _ => Ok(ToolResult::error(format!("Unknown tool: {}", name))),
383        }?;
384
385        Ok(serde_json::to_value(result)?)
386    }
387
388    // ==================== Tool Implementations ====================
389
390    fn ensure_index(&mut self) -> Result<()> {
391        if self.store.is_some() {
392            return Ok(());
393        }
394
395        // Check if daemon is running and index exists
396        use crate::daemon;
397
398        if !self.db_path.exists() {
399            // Start daemon in background
400            log::info!("Index not found. Starting daemon...");
401            daemon::start(&self.workspace_root, &self.db_path, false, true, None)?;
402
403            // Wait for index to be ready
404            let max_wait = std::time::Duration::from_secs(60);
405            let start = std::time::Instant::now();
406            while !self.db_path.exists() && start.elapsed() < max_wait {
407                std::thread::sleep(std::time::Duration::from_millis(500));
408            }
409
410            if !self.db_path.exists() {
411                bail!("Daemon started but index not created within 60 seconds");
412            }
413        }
414
415        // Open the store
416        self.store = Some(IndexStore::open(&self.db_path)?);
417        Ok(())
418    }
419
420    fn get_store(&mut self) -> Result<&IndexStore> {
421        self.ensure_index()?;
422        self.store
423            .as_ref()
424            .ok_or_else(|| anyhow::anyhow!("Store not initialized"))
425    }
426
427    fn tool_symbols(&mut self, args: &Value) -> Result<ToolResult> {
428        let store = self.get_store()?;
429
430        let name = args.get("name").and_then(|v| v.as_str());
431        let kind = args.get("kind").and_then(|v| v.as_str());
432        let file = args.get("file").and_then(|v| v.as_str());
433        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
434
435        let symbols = store.list_symbols(file, kind, name, Some(limit))?;
436
437        if symbols.is_empty() {
438            return Ok(ToolResult::text("No symbols found matching the criteria."));
439        }
440
441        let output = format_symbols(&symbols, &self.workspace_root);
442        Ok(ToolResult::text(output))
443    }
444
445    fn tool_symbol(&mut self, args: &Value) -> Result<ToolResult> {
446        let store = self.get_store()?;
447
448        let name = args
449            .get("name")
450            .and_then(|v| v.as_str())
451            .ok_or_else(|| anyhow::anyhow!("Missing 'name' argument"))?;
452
453        let kind = args.get("kind").and_then(|v| v.as_str());
454
455        let symbols = store.list_symbols(None, kind, Some(name), Some(10))?;
456
457        if symbols.is_empty() {
458            return Ok(ToolResult::text(format!(
459                "No symbol found with name '{}'",
460                name
461            )));
462        }
463
464        let output = format_symbols(&symbols, &self.workspace_root);
465        Ok(ToolResult::text(output))
466    }
467
468    fn tool_definition(&mut self, args: &Value) -> Result<ToolResult> {
469        let file = args
470            .get("file")
471            .and_then(|v| v.as_str())
472            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
473        let line = args
474            .get("line")
475            .and_then(|v| v.as_u64())
476            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
477        let character = args
478            .get("character")
479            .and_then(|v| v.as_u64())
480            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
481            as usize;
482
483        let file_path = self.resolve_path(file);
484
485        // Find symbol at position
486        if let Some(symbol) = self.find_symbol_at(&file_path, line, character)? {
487            let output = format_symbol(&symbol, &self.workspace_root);
488            return Ok(ToolResult::text(format!("Definition:\n{}", output)));
489        }
490
491        Ok(ToolResult::text(format!(
492            "No symbol found at {}:{}:{}",
493            file, line, character
494        )))
495    }
496
497    fn tool_usages(&mut self, args: &Value) -> Result<ToolResult> {
498        let file = args
499            .get("file")
500            .and_then(|v| v.as_str())
501            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
502        let line = args
503            .get("line")
504            .and_then(|v| v.as_u64())
505            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
506        let character = args
507            .get("character")
508            .and_then(|v| v.as_u64())
509            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
510            as usize;
511        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
512
513        let file_path = self.resolve_path(file);
514
515        // Find symbol at position
516        let symbol = match self.find_symbol_at(&file_path, line, character)? {
517            Some(s) => s,
518            None => {
519                return Ok(ToolResult::text(format!(
520                    "No symbol found at {}:{}:{}",
521                    file, line, character
522                )));
523            }
524        };
525
526        // Find references using references_for_symbol
527        let store = self.get_store()?;
528        let refs = store.references_for_symbol(&symbol.id)?;
529
530        if refs.is_empty() {
531            return Ok(ToolResult::text(format!(
532                "No usages found for '{}'",
533                symbol.name
534            )));
535        }
536
537        let refs: Vec<_> = refs.into_iter().take(limit).collect();
538        let mut output = format!("Usages of '{}' ({} found):\n\n", symbol.name, refs.len());
539        for r in &refs {
540            let rel_path = self.relative_path(&r.file);
541            // Convert offset to line:col
542            if let Ok((ref_line, ref_col)) = offset_to_line_col(&r.file, r.start as usize) {
543                output.push_str(&format!("  {}:{}:{}\n", rel_path, ref_line, ref_col));
544            }
545        }
546
547        Ok(ToolResult::text(output))
548    }
549
550    fn tool_implementations(&mut self, args: &Value) -> Result<ToolResult> {
551        let file = args
552            .get("file")
553            .and_then(|v| v.as_str())
554            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
555        let line = args
556            .get("line")
557            .and_then(|v| v.as_u64())
558            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
559        let character = args
560            .get("character")
561            .and_then(|v| v.as_u64())
562            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
563            as usize;
564        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
565
566        let file_path = self.resolve_path(file);
567
568        // Find symbol at position
569        let symbol = match self.find_symbol_at(&file_path, line, character)? {
570            Some(s) => s,
571            None => {
572                return Ok(ToolResult::text(format!(
573                    "No symbol found at {}:{}:{}",
574                    file, line, character
575                )));
576            }
577        };
578
579        // Find implementations via edges_to (edges pointing TO the symbol from implementations)
580        let store = self.get_store()?;
581        let edges = store.edges_to(&symbol.id)?;
582        let impl_ids: Vec<String> = edges.into_iter().map(|e| e.src).collect();
583        let mut impls = store.symbols_by_ids(&impl_ids)?;
584
585        if impls.is_empty() {
586            // Fallback: search by name
587            let fallback = store.list_symbols(None, None, Some(&symbol.name), Some(limit))?;
588            if fallback.len() <= 1 {
589                return Ok(ToolResult::text(format!(
590                    "No implementations found for '{}'",
591                    symbol.name
592                )));
593            }
594            let output = format_symbols(&fallback, &self.workspace_root);
595            return Ok(ToolResult::text(format!(
596                "Implementations of '{}' (by name):\n\n{}",
597                symbol.name, output
598            )));
599        }
600
601        impls.truncate(limit);
602        let output = format_symbols(&impls, &self.workspace_root);
603        Ok(ToolResult::text(format!(
604            "Implementations of '{}':\n\n{}",
605            symbol.name, output
606        )))
607    }
608
609    fn tool_daemon_status(&mut self) -> Result<ToolResult> {
610        use crate::daemon;
611
612        let status = if let Ok(Some(pid_info)) = daemon::read_pid_file(&self.workspace_root) {
613            if daemon::is_process_running(pid_info.pid) {
614                format!(
615                    "Daemon: running (PID {})\nVersion: {}\nWorkspace: {}\nDatabase: {}",
616                    pid_info.pid,
617                    pid_info.version,
618                    self.workspace_root.display(),
619                    self.db_path.display()
620                )
621            } else {
622                format!(
623                    "Daemon: not running (stale PID file)\nWorkspace: {}\nDatabase: {}",
624                    self.workspace_root.display(),
625                    self.db_path.display()
626                )
627            }
628        } else {
629            format!(
630                "Daemon: not running\nWorkspace: {}\nDatabase: {}",
631                self.workspace_root.display(),
632                self.db_path.display()
633            )
634        };
635
636        Ok(ToolResult::text(status))
637    }
638
639    // ==================== Helper Methods ====================
640
641    fn resolve_path(&self, path: &str) -> PathBuf {
642        let p = PathBuf::from(path);
643        if p.is_absolute() {
644            p
645        } else {
646            self.workspace_root.join(p)
647        }
648    }
649
650    fn relative_path(&self, path: &str) -> String {
651        let p = PathBuf::from(path);
652        p.strip_prefix(&self.workspace_root)
653            .map(|p| p.to_string_lossy().to_string())
654            .unwrap_or_else(|_| path.to_string())
655    }
656
657    fn find_symbol_at(
658        &mut self,
659        file: &Path,
660        line: usize,
661        character: usize,
662    ) -> Result<Option<SymbolRecord>> {
663        let store = self.get_store()?;
664        let file_str = file.to_string_lossy().to_string();
665
666        // Convert line:col to byte offset
667        let content = std::fs::read_to_string(file)?;
668        let mut offset: i64 = 0;
669        for (i, l) in content.lines().enumerate() {
670            if i + 1 == line {
671                offset += character.saturating_sub(1) as i64;
672                break;
673            }
674            offset += l.len() as i64 + 1; // +1 for newline
675        }
676
677        // Find symbol containing this offset
678        let symbols = store.list_symbols(Some(&file_str), None, None, None)?;
679
680        // Find the narrowest symbol containing the offset
681        let mut best: Option<SymbolRecord> = None;
682        for sym in symbols {
683            if sym.start <= offset && offset < sym.end {
684                let span = sym.end - sym.start;
685                if best
686                    .as_ref()
687                    .map(|b| span < (b.end - b.start))
688                    .unwrap_or(true)
689                {
690                    best = Some(sym);
691                }
692            }
693        }
694
695        Ok(best)
696    }
697}
698
699// ==================== Formatting Helpers ====================
700
701fn format_symbols(symbols: &[SymbolRecord], workspace_root: &Path) -> String {
702    let mut output = String::new();
703    for sym in symbols {
704        output.push_str(&format_symbol(sym, workspace_root));
705        output.push('\n');
706    }
707    output
708}
709
710fn format_symbol(sym: &SymbolRecord, workspace_root: &Path) -> String {
711    let rel_path = PathBuf::from(&sym.file)
712        .strip_prefix(workspace_root)
713        .map(|p| p.to_string_lossy().to_string())
714        .unwrap_or_else(|_| sym.file.clone());
715
716    // Convert byte offset to line:col
717    let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
718        format!("{}:{}:{}", rel_path, line, col)
719    } else {
720        format!("{}:offset:{}", rel_path, sym.start)
721    };
722
723    let mut parts = vec![format!("{:<10} {:<30} {}", sym.kind, sym.name, location)];
724
725    if let Some(ref vis) = sym.visibility {
726        parts.push(format!("  visibility: {}", vis));
727    }
728    if let Some(ref container) = sym.container {
729        parts.push(format!("  container: {}", container));
730    }
731
732    parts.join("\n")
733}
734
735/// Convert byte offset to 1-based line:column
736fn offset_to_line_col(file_path: &str, offset: usize) -> Result<(usize, usize)> {
737    let content = std::fs::read(file_path)?;
738    let mut line = 1;
739    let mut col = 1;
740    for (i, &b) in content.iter().enumerate() {
741        if i == offset {
742            return Ok((line, col));
743        }
744        if b == b'\n' {
745            line += 1;
746            col = 1;
747        } else {
748            col += 1;
749        }
750    }
751    if offset == content.len() {
752        Ok((line, col))
753    } else {
754        anyhow::bail!("offset out of bounds")
755    }
756}
757
758/// Run the MCP server with the given workspace and database paths.
759pub fn run_server(workspace_root: &Path, db_path: &Path) -> Result<()> {
760    let mut server = McpServer::new(workspace_root.to_path_buf(), db_path.to_path_buf());
761    server.run()
762}