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: concat!(
240                    "Search for code symbols (functions, classes, interfaces, types, structs, enums, traits) in the indexed codebase. ",
241                    "USE THIS INSTEAD OF grep/ripgrep when: finding where a function or class is defined, ",
242                    "exploring what methods/functions exist, listing symbols in a file, or searching by symbol kind. ",
243                    "Returns precise file:line:column locations. Faster and more accurate than text search for code navigation. ",
244                    "Supports TypeScript, Rust, and Kotlin."
245                ).to_string(),
246                input_schema: json!({
247                    "type": "object",
248                    "properties": {
249                        "name": {
250                            "type": "string",
251                            "description": "Filter by symbol name (exact match). Use this when you know the name you're looking for."
252                        },
253                        "kind": {
254                            "type": "string",
255                            "description": "Filter by symbol kind: function, class, interface, type, struct, enum, trait, method, const, variable"
256                        },
257                        "file": {
258                            "type": "string",
259                            "description": "Filter to symbols in this file path. Use to explore a specific file's structure."
260                        },
261                        "limit": {
262                            "type": "integer",
263                            "description": "Maximum number of results (default: 50). Increase for comprehensive searches."
264                        }
265                    }
266                }),
267            },
268            Tool {
269                name: "gabb_symbol".to_string(),
270                description: concat!(
271                    "Get detailed information about a symbol when you know its name. ",
272                    "USE THIS when you have a specific symbol name and want to find where it's defined. ",
273                    "Returns the symbol's location, kind, visibility, and container. ",
274                    "For exploring unknown code, use gabb_symbols instead."
275                ).to_string(),
276                input_schema: json!({
277                    "type": "object",
278                    "properties": {
279                        "name": {
280                            "type": "string",
281                            "description": "The exact symbol name to look up (e.g., 'MyClass', 'process_data', 'UserService')"
282                        },
283                        "kind": {
284                            "type": "string",
285                            "description": "Optionally filter by kind if the name is ambiguous (function, class, interface, etc.)"
286                        }
287                    },
288                    "required": ["name"]
289                }),
290            },
291            Tool {
292                name: "gabb_definition".to_string(),
293                description: concat!(
294                    "Jump from a symbol usage to its definition/declaration. ",
295                    "USE THIS when you see a function call, type reference, or variable and want to see where it's defined. ",
296                    "Works across files and through imports. Provide the file and position where the symbol is USED, ",
297                    "and this returns where it's DEFINED. Essential for understanding unfamiliar code."
298                ).to_string(),
299                input_schema: json!({
300                    "type": "object",
301                    "properties": {
302                        "file": {
303                            "type": "string",
304                            "description": "Path to the file containing the symbol usage (absolute or relative to workspace)"
305                        },
306                        "line": {
307                            "type": "integer",
308                            "description": "1-based line number where the symbol appears"
309                        },
310                        "character": {
311                            "type": "integer",
312                            "description": "1-based column number (position within the line)"
313                        }
314                    },
315                    "required": ["file", "line", "character"]
316                }),
317            },
318            Tool {
319                name: "gabb_usages".to_string(),
320                description: concat!(
321                    "Find ALL places where a symbol is used/referenced across the codebase. ",
322                    "USE THIS BEFORE REFACTORING to understand impact, when investigating how a function is called, ",
323                    "or to find all consumers of an API. More accurate than text search - understands code structure ",
324                    "and won't match comments or strings. Point to a symbol definition to find all its usages."
325                ).to_string(),
326                input_schema: json!({
327                    "type": "object",
328                    "properties": {
329                        "file": {
330                            "type": "string",
331                            "description": "Path to the file containing the symbol definition"
332                        },
333                        "line": {
334                            "type": "integer",
335                            "description": "1-based line number of the symbol"
336                        },
337                        "character": {
338                            "type": "integer",
339                            "description": "1-based column number within the line"
340                        },
341                        "limit": {
342                            "type": "integer",
343                            "description": "Maximum usages to return (default: 50). Increase for thorough analysis."
344                        }
345                    },
346                    "required": ["file", "line", "character"]
347                }),
348            },
349            Tool {
350                name: "gabb_implementations".to_string(),
351                description: concat!(
352                    "Find all implementations of an interface, trait, or abstract class. ",
353                    "USE THIS when you have an interface/trait and want to find concrete implementations, ",
354                    "or when exploring a codebase's architecture to understand what classes implement a contract. ",
355                    "Point to the interface/trait definition to find all implementing classes/structs."
356                ).to_string(),
357                input_schema: json!({
358                    "type": "object",
359                    "properties": {
360                        "file": {
361                            "type": "string",
362                            "description": "Path to the file containing the interface/trait definition"
363                        },
364                        "line": {
365                            "type": "integer",
366                            "description": "1-based line number of the interface/trait"
367                        },
368                        "character": {
369                            "type": "integer",
370                            "description": "1-based column number within the line"
371                        },
372                        "limit": {
373                            "type": "integer",
374                            "description": "Maximum implementations to return (default: 50)"
375                        }
376                    },
377                    "required": ["file", "line", "character"]
378                }),
379            },
380            Tool {
381                name: "gabb_daemon_status".to_string(),
382                description: concat!(
383                    "Check if the gabb indexing daemon is running and get workspace info. ",
384                    "USE THIS to diagnose issues if other gabb tools aren't working, ",
385                    "or to verify the index is up-to-date. Returns daemon PID, version, and index location."
386                ).to_string(),
387                input_schema: json!({
388                    "type": "object",
389                    "properties": {}
390                }),
391            },
392        ];
393
394        Ok(json!({ "tools": tools }))
395    }
396
397    fn handle_tools_call(&mut self, params: &Value) -> Result<Value> {
398        let name = params
399            .get("name")
400            .and_then(|v| v.as_str())
401            .ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
402
403        let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
404
405        let result = match name {
406            "gabb_symbols" => self.tool_symbols(&arguments),
407            "gabb_symbol" => self.tool_symbol(&arguments),
408            "gabb_definition" => self.tool_definition(&arguments),
409            "gabb_usages" => self.tool_usages(&arguments),
410            "gabb_implementations" => self.tool_implementations(&arguments),
411            "gabb_daemon_status" => self.tool_daemon_status(),
412            _ => Ok(ToolResult::error(format!("Unknown tool: {}", name))),
413        }?;
414
415        Ok(serde_json::to_value(result)?)
416    }
417
418    // ==================== Tool Implementations ====================
419
420    fn ensure_index(&mut self) -> Result<()> {
421        if self.store.is_some() {
422            return Ok(());
423        }
424
425        // Check if daemon is running and index exists
426        use crate::daemon;
427
428        if !self.db_path.exists() {
429            // Start daemon in background
430            log::info!("Index not found. Starting daemon...");
431            daemon::start(&self.workspace_root, &self.db_path, false, true, None)?;
432
433            // Wait for index to be ready
434            let max_wait = std::time::Duration::from_secs(60);
435            let start = std::time::Instant::now();
436            while !self.db_path.exists() && start.elapsed() < max_wait {
437                std::thread::sleep(std::time::Duration::from_millis(500));
438            }
439
440            if !self.db_path.exists() {
441                bail!("Daemon started but index not created within 60 seconds");
442            }
443        }
444
445        // Open the store
446        self.store = Some(IndexStore::open(&self.db_path)?);
447        Ok(())
448    }
449
450    fn get_store(&mut self) -> Result<&IndexStore> {
451        self.ensure_index()?;
452        self.store
453            .as_ref()
454            .ok_or_else(|| anyhow::anyhow!("Store not initialized"))
455    }
456
457    fn tool_symbols(&mut self, args: &Value) -> Result<ToolResult> {
458        let store = self.get_store()?;
459
460        let name = args.get("name").and_then(|v| v.as_str());
461        let kind = args.get("kind").and_then(|v| v.as_str());
462        let file = args.get("file").and_then(|v| v.as_str());
463        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
464
465        let symbols = store.list_symbols(file, kind, name, Some(limit))?;
466
467        if symbols.is_empty() {
468            return Ok(ToolResult::text("No symbols found matching the criteria."));
469        }
470
471        let output = format_symbols(&symbols, &self.workspace_root);
472        Ok(ToolResult::text(output))
473    }
474
475    fn tool_symbol(&mut self, args: &Value) -> Result<ToolResult> {
476        let store = self.get_store()?;
477
478        let name = args
479            .get("name")
480            .and_then(|v| v.as_str())
481            .ok_or_else(|| anyhow::anyhow!("Missing 'name' argument"))?;
482
483        let kind = args.get("kind").and_then(|v| v.as_str());
484
485        let symbols = store.list_symbols(None, kind, Some(name), Some(10))?;
486
487        if symbols.is_empty() {
488            return Ok(ToolResult::text(format!(
489                "No symbol found with name '{}'",
490                name
491            )));
492        }
493
494        let output = format_symbols(&symbols, &self.workspace_root);
495        Ok(ToolResult::text(output))
496    }
497
498    fn tool_definition(&mut self, args: &Value) -> Result<ToolResult> {
499        let file = args
500            .get("file")
501            .and_then(|v| v.as_str())
502            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
503        let line = args
504            .get("line")
505            .and_then(|v| v.as_u64())
506            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
507        let character = args
508            .get("character")
509            .and_then(|v| v.as_u64())
510            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
511            as usize;
512
513        let file_path = self.resolve_path(file);
514
515        // Find symbol at position
516        if let Some(symbol) = self.find_symbol_at(&file_path, line, character)? {
517            let output = format_symbol(&symbol, &self.workspace_root);
518            return Ok(ToolResult::text(format!("Definition:\n{}", output)));
519        }
520
521        Ok(ToolResult::text(format!(
522            "No symbol found at {}:{}:{}",
523            file, line, character
524        )))
525    }
526
527    fn tool_usages(&mut self, args: &Value) -> Result<ToolResult> {
528        let file = args
529            .get("file")
530            .and_then(|v| v.as_str())
531            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
532        let line = args
533            .get("line")
534            .and_then(|v| v.as_u64())
535            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
536        let character = args
537            .get("character")
538            .and_then(|v| v.as_u64())
539            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
540            as usize;
541        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
542
543        let file_path = self.resolve_path(file);
544
545        // Find symbol at position
546        let symbol = match self.find_symbol_at(&file_path, line, character)? {
547            Some(s) => s,
548            None => {
549                return Ok(ToolResult::text(format!(
550                    "No symbol found at {}:{}:{}",
551                    file, line, character
552                )));
553            }
554        };
555
556        // Find references using references_for_symbol
557        let store = self.get_store()?;
558        let refs = store.references_for_symbol(&symbol.id)?;
559
560        if refs.is_empty() {
561            return Ok(ToolResult::text(format!(
562                "No usages found for '{}'",
563                symbol.name
564            )));
565        }
566
567        let refs: Vec<_> = refs.into_iter().take(limit).collect();
568        let mut output = format!("Usages of '{}' ({} found):\n\n", symbol.name, refs.len());
569        for r in &refs {
570            let rel_path = self.relative_path(&r.file);
571            // Convert offset to line:col
572            if let Ok((ref_line, ref_col)) = offset_to_line_col(&r.file, r.start as usize) {
573                output.push_str(&format!("  {}:{}:{}\n", rel_path, ref_line, ref_col));
574            }
575        }
576
577        Ok(ToolResult::text(output))
578    }
579
580    fn tool_implementations(&mut self, args: &Value) -> Result<ToolResult> {
581        let file = args
582            .get("file")
583            .and_then(|v| v.as_str())
584            .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
585        let line = args
586            .get("line")
587            .and_then(|v| v.as_u64())
588            .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
589        let character = args
590            .get("character")
591            .and_then(|v| v.as_u64())
592            .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
593            as usize;
594        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
595
596        let file_path = self.resolve_path(file);
597
598        // Find symbol at position
599        let symbol = match self.find_symbol_at(&file_path, line, character)? {
600            Some(s) => s,
601            None => {
602                return Ok(ToolResult::text(format!(
603                    "No symbol found at {}:{}:{}",
604                    file, line, character
605                )));
606            }
607        };
608
609        // Find implementations via edges_to (edges pointing TO the symbol from implementations)
610        let store = self.get_store()?;
611        let edges = store.edges_to(&symbol.id)?;
612        let impl_ids: Vec<String> = edges.into_iter().map(|e| e.src).collect();
613        let mut impls = store.symbols_by_ids(&impl_ids)?;
614
615        if impls.is_empty() {
616            // Fallback: search by name
617            let fallback = store.list_symbols(None, None, Some(&symbol.name), Some(limit))?;
618            if fallback.len() <= 1 {
619                return Ok(ToolResult::text(format!(
620                    "No implementations found for '{}'",
621                    symbol.name
622                )));
623            }
624            let output = format_symbols(&fallback, &self.workspace_root);
625            return Ok(ToolResult::text(format!(
626                "Implementations of '{}' (by name):\n\n{}",
627                symbol.name, output
628            )));
629        }
630
631        impls.truncate(limit);
632        let output = format_symbols(&impls, &self.workspace_root);
633        Ok(ToolResult::text(format!(
634            "Implementations of '{}':\n\n{}",
635            symbol.name, output
636        )))
637    }
638
639    fn tool_daemon_status(&mut self) -> Result<ToolResult> {
640        use crate::daemon;
641
642        let status = if let Ok(Some(pid_info)) = daemon::read_pid_file(&self.workspace_root) {
643            if daemon::is_process_running(pid_info.pid) {
644                format!(
645                    "Daemon: running (PID {})\nVersion: {}\nWorkspace: {}\nDatabase: {}",
646                    pid_info.pid,
647                    pid_info.version,
648                    self.workspace_root.display(),
649                    self.db_path.display()
650                )
651            } else {
652                format!(
653                    "Daemon: not running (stale PID file)\nWorkspace: {}\nDatabase: {}",
654                    self.workspace_root.display(),
655                    self.db_path.display()
656                )
657            }
658        } else {
659            format!(
660                "Daemon: not running\nWorkspace: {}\nDatabase: {}",
661                self.workspace_root.display(),
662                self.db_path.display()
663            )
664        };
665
666        Ok(ToolResult::text(status))
667    }
668
669    // ==================== Helper Methods ====================
670
671    fn resolve_path(&self, path: &str) -> PathBuf {
672        let p = PathBuf::from(path);
673        if p.is_absolute() {
674            p
675        } else {
676            self.workspace_root.join(p)
677        }
678    }
679
680    fn relative_path(&self, path: &str) -> String {
681        let p = PathBuf::from(path);
682        p.strip_prefix(&self.workspace_root)
683            .map(|p| p.to_string_lossy().to_string())
684            .unwrap_or_else(|_| path.to_string())
685    }
686
687    fn find_symbol_at(
688        &mut self,
689        file: &Path,
690        line: usize,
691        character: usize,
692    ) -> Result<Option<SymbolRecord>> {
693        let store = self.get_store()?;
694        let file_str = file.to_string_lossy().to_string();
695
696        // Convert line:col to byte offset
697        let content = std::fs::read_to_string(file)?;
698        let mut offset: i64 = 0;
699        for (i, l) in content.lines().enumerate() {
700            if i + 1 == line {
701                offset += character.saturating_sub(1) as i64;
702                break;
703            }
704            offset += l.len() as i64 + 1; // +1 for newline
705        }
706
707        // Find symbol containing this offset
708        let symbols = store.list_symbols(Some(&file_str), None, None, None)?;
709
710        // Find the narrowest symbol containing the offset
711        let mut best: Option<SymbolRecord> = None;
712        for sym in symbols {
713            if sym.start <= offset && offset < sym.end {
714                let span = sym.end - sym.start;
715                if best
716                    .as_ref()
717                    .map(|b| span < (b.end - b.start))
718                    .unwrap_or(true)
719                {
720                    best = Some(sym);
721                }
722            }
723        }
724
725        Ok(best)
726    }
727}
728
729// ==================== Formatting Helpers ====================
730
731fn format_symbols(symbols: &[SymbolRecord], workspace_root: &Path) -> String {
732    let mut output = String::new();
733    for sym in symbols {
734        output.push_str(&format_symbol(sym, workspace_root));
735        output.push('\n');
736    }
737    output
738}
739
740fn format_symbol(sym: &SymbolRecord, workspace_root: &Path) -> String {
741    let rel_path = PathBuf::from(&sym.file)
742        .strip_prefix(workspace_root)
743        .map(|p| p.to_string_lossy().to_string())
744        .unwrap_or_else(|_| sym.file.clone());
745
746    // Convert byte offset to line:col
747    let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
748        format!("{}:{}:{}", rel_path, line, col)
749    } else {
750        format!("{}:offset:{}", rel_path, sym.start)
751    };
752
753    let mut parts = vec![format!("{:<10} {:<30} {}", sym.kind, sym.name, location)];
754
755    if let Some(ref vis) = sym.visibility {
756        parts.push(format!("  visibility: {}", vis));
757    }
758    if let Some(ref container) = sym.container {
759        parts.push(format!("  container: {}", container));
760    }
761
762    parts.join("\n")
763}
764
765/// Convert byte offset to 1-based line:column
766fn offset_to_line_col(file_path: &str, offset: usize) -> Result<(usize, usize)> {
767    let content = std::fs::read(file_path)?;
768    let mut line = 1;
769    let mut col = 1;
770    for (i, &b) in content.iter().enumerate() {
771        if i == offset {
772            return Ok((line, col));
773        }
774        if b == b'\n' {
775            line += 1;
776            col = 1;
777        } else {
778            col += 1;
779        }
780    }
781    if offset == content.len() {
782        Ok((line, col))
783    } else {
784        anyhow::bail!("offset out of bounds")
785    }
786}
787
788/// Run the MCP server with the given workspace and database paths.
789pub fn run_server(workspace_root: &Path, db_path: &Path) -> Result<()> {
790    let mut server = McpServer::new(workspace_root.to_path_buf(), db_path.to_path_buf());
791    server.run()
792}