1use crate::store::{DuplicateGroup, IndexStore, SymbolRecord};
11use anyhow::{bail, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use std::collections::HashMap;
15use std::io::{self, BufRead, Write};
16use std::path::{Path, PathBuf};
17
18const 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
33const WORKSPACE_DIR_MARKERS: &[&str] = &["gradle", ".git"];
35
36const MAX_CACHED_WORKSPACES: usize = 5;
38
39const PROTOCOL_VERSION: &str = "2024-11-05";
41
42const SERVER_NAME: &str = "gabb";
44const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
45
46#[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
100const PARSE_ERROR: i32 = -32700;
102const INTERNAL_ERROR: i32 = -32603;
103
104#[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
150struct WorkspaceInfo {
154 root: PathBuf,
155 db_path: PathBuf,
156 store: Option<IndexStore>,
157 last_used: std::time::Instant,
158}
159
160pub struct McpServer {
162 default_workspace: PathBuf,
164 default_db_path: PathBuf,
166 workspace_cache: HashMap<PathBuf, WorkspaceInfo>,
168 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 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 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 let id = match request.id {
219 Some(id) => id,
220 None => {
221 self.handle_notification(&request.method, &request.params);
223 return None;
224 }
225 };
226
227 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 let default_workspace = self.default_workspace.clone();
258 self.ensure_workspace_index(&default_workspace)?;
259
260 Ok(json!({
261 "protocolVersion": PROTOCOL_VERSION,
262 "capabilities": {
263 "tools": {
264 "listChanged": false
265 }
266 },
267 "serverInfo": {
268 "name": SERVER_NAME,
269 "version": SERVER_VERSION
270 }
271 }))
272 }
273
274 fn handle_tools_list(&self) -> Result<Value> {
275 let tools = vec![
276 Tool {
277 name: "gabb_symbols".to_string(),
278 description: concat!(
279 "Search for code symbols (functions, classes, interfaces, types, structs, enums, traits) in the indexed codebase. ",
280 "USE THIS INSTEAD OF grep/ripgrep when: finding where a function or class is defined, ",
281 "exploring what methods/functions exist, listing symbols in a file, or searching by symbol kind. ",
282 "Returns precise file:line:column locations. Faster and more accurate than text search for code navigation. ",
283 "Supports TypeScript, Rust, and Kotlin."
284 ).to_string(),
285 input_schema: json!({
286 "type": "object",
287 "properties": {
288 "name": {
289 "type": "string",
290 "description": "Filter by symbol name (exact match). Use this when you know the name you're looking for."
291 },
292 "kind": {
293 "type": "string",
294 "description": "Filter by symbol kind: function, class, interface, type, struct, enum, trait, method, const, variable"
295 },
296 "file": {
297 "type": "string",
298 "description": "Filter to symbols in this file path. Use to explore a specific file's structure."
299 },
300 "limit": {
301 "type": "integer",
302 "description": "Maximum number of results (default: 50). Increase for comprehensive searches."
303 }
304 }
305 }),
306 },
307 Tool {
308 name: "gabb_symbol".to_string(),
309 description: concat!(
310 "Get detailed information about a symbol when you know its name. ",
311 "USE THIS when you have a specific symbol name and want to find where it's defined. ",
312 "Returns the symbol's location, kind, visibility, and container. ",
313 "For exploring unknown code, use gabb_symbols instead."
314 ).to_string(),
315 input_schema: json!({
316 "type": "object",
317 "properties": {
318 "name": {
319 "type": "string",
320 "description": "The exact symbol name to look up (e.g., 'MyClass', 'process_data', 'UserService')"
321 },
322 "kind": {
323 "type": "string",
324 "description": "Optionally filter by kind if the name is ambiguous (function, class, interface, etc.)"
325 }
326 },
327 "required": ["name"]
328 }),
329 },
330 Tool {
331 name: "gabb_definition".to_string(),
332 description: concat!(
333 "Jump from a symbol usage to its definition/declaration. ",
334 "USE THIS when you see a function call, type reference, or variable and want to see where it's defined. ",
335 "Works across files and through imports. Provide the file and position where the symbol is USED, ",
336 "and this returns where it's DEFINED. Essential for understanding unfamiliar code."
337 ).to_string(),
338 input_schema: json!({
339 "type": "object",
340 "properties": {
341 "file": {
342 "type": "string",
343 "description": "Path to the file containing the symbol usage (absolute or relative to workspace)"
344 },
345 "line": {
346 "type": "integer",
347 "description": "1-based line number where the symbol appears"
348 },
349 "character": {
350 "type": "integer",
351 "description": "1-based column number (position within the line)"
352 }
353 },
354 "required": ["file", "line", "character"]
355 }),
356 },
357 Tool {
358 name: "gabb_usages".to_string(),
359 description: concat!(
360 "Find ALL places where a symbol is used/referenced across the codebase. ",
361 "USE THIS BEFORE REFACTORING to understand impact, when investigating how a function is called, ",
362 "or to find all consumers of an API. More accurate than text search - understands code structure ",
363 "and won't match comments or strings. Point to a symbol definition to find all its usages."
364 ).to_string(),
365 input_schema: json!({
366 "type": "object",
367 "properties": {
368 "file": {
369 "type": "string",
370 "description": "Path to the file containing the symbol definition"
371 },
372 "line": {
373 "type": "integer",
374 "description": "1-based line number of the symbol"
375 },
376 "character": {
377 "type": "integer",
378 "description": "1-based column number within the line"
379 },
380 "limit": {
381 "type": "integer",
382 "description": "Maximum usages to return (default: 50). Increase for thorough analysis."
383 }
384 },
385 "required": ["file", "line", "character"]
386 }),
387 },
388 Tool {
389 name: "gabb_implementations".to_string(),
390 description: concat!(
391 "Find all implementations of an interface, trait, or abstract class. ",
392 "USE THIS when you have an interface/trait and want to find concrete implementations, ",
393 "or when exploring a codebase's architecture to understand what classes implement a contract. ",
394 "Point to the interface/trait definition to find all implementing classes/structs."
395 ).to_string(),
396 input_schema: json!({
397 "type": "object",
398 "properties": {
399 "file": {
400 "type": "string",
401 "description": "Path to the file containing the interface/trait definition"
402 },
403 "line": {
404 "type": "integer",
405 "description": "1-based line number of the interface/trait"
406 },
407 "character": {
408 "type": "integer",
409 "description": "1-based column number within the line"
410 },
411 "limit": {
412 "type": "integer",
413 "description": "Maximum implementations to return (default: 50)"
414 }
415 },
416 "required": ["file", "line", "character"]
417 }),
418 },
419 Tool {
420 name: "gabb_daemon_status".to_string(),
421 description: concat!(
422 "Check if the gabb indexing daemon is running and get workspace info. ",
423 "USE THIS to diagnose issues if other gabb tools aren't working, ",
424 "or to verify the index is up-to-date. Returns daemon PID, version, and index location."
425 ).to_string(),
426 input_schema: json!({
427 "type": "object",
428 "properties": {}
429 }),
430 },
431 Tool {
432 name: "gabb_duplicates".to_string(),
433 description: concat!(
434 "Find duplicate or near-duplicate code blocks in the codebase. ",
435 "USE THIS to identify copy-paste code, find refactoring opportunities, ",
436 "or detect code that should be consolidated into shared utilities. ",
437 "Groups symbols (functions, methods, classes) by identical content hash. ",
438 "Can filter by symbol kind or focus on recently changed files."
439 ).to_string(),
440 input_schema: json!({
441 "type": "object",
442 "properties": {
443 "kind": {
444 "type": "string",
445 "description": "Filter by symbol kind: function, method, class, etc. Useful to focus on function duplicates."
446 },
447 "min_count": {
448 "type": "integer",
449 "description": "Minimum number of duplicates to report (default: 2). Increase to find more widespread duplication."
450 },
451 "limit": {
452 "type": "integer",
453 "description": "Maximum number of duplicate groups to return (default: 20)"
454 }
455 }
456 }),
457 },
458 ];
459
460 Ok(json!({ "tools": tools }))
461 }
462
463 fn handle_tools_call(&mut self, params: &Value) -> Result<Value> {
464 let name = params
465 .get("name")
466 .and_then(|v| v.as_str())
467 .ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
468
469 let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
470
471 let result = match name {
472 "gabb_symbols" => self.tool_symbols(&arguments),
473 "gabb_symbol" => self.tool_symbol(&arguments),
474 "gabb_definition" => self.tool_definition(&arguments),
475 "gabb_usages" => self.tool_usages(&arguments),
476 "gabb_implementations" => self.tool_implementations(&arguments),
477 "gabb_daemon_status" => self.tool_daemon_status(),
478 "gabb_duplicates" => self.tool_duplicates(&arguments),
479 _ => Ok(ToolResult::error(format!("Unknown tool: {}", name))),
480 }?;
481
482 Ok(serde_json::to_value(result)?)
483 }
484
485 fn infer_workspace(&self, file_path: &Path) -> Option<PathBuf> {
489 let file_path = if file_path.is_absolute() {
490 file_path.to_path_buf()
491 } else {
492 self.default_workspace.join(file_path)
493 };
494
495 let mut current = file_path.parent()?;
496
497 loop {
498 for marker in WORKSPACE_MARKERS {
500 if current.join(marker).exists() {
501 return Some(current.to_path_buf());
502 }
503 }
504
505 for marker in WORKSPACE_DIR_MARKERS {
507 let marker_path = current.join(marker);
508 if marker_path.is_dir() {
509 return Some(current.to_path_buf());
510 }
511 }
512
513 current = current.parent()?;
515 }
516 }
517
518 fn get_or_create_workspace(&mut self, workspace_root: &Path) -> Result<&mut WorkspaceInfo> {
520 let workspace_root = workspace_root
521 .canonicalize()
522 .unwrap_or_else(|_| workspace_root.to_path_buf());
523
524 if !self.workspace_cache.contains_key(&workspace_root)
526 && self.workspace_cache.len() >= MAX_CACHED_WORKSPACES
527 {
528 if let Some(oldest_key) = self
530 .workspace_cache
531 .iter()
532 .min_by_key(|(_, info)| info.last_used)
533 .map(|(k, _)| k.clone())
534 {
535 log::debug!("Evicting workspace from cache: {}", oldest_key.display());
536 self.workspace_cache.remove(&oldest_key);
537 }
538 }
539
540 if !self.workspace_cache.contains_key(&workspace_root) {
542 let db_path = workspace_root.join(".gabb/index.db");
543 log::debug!(
544 "Adding workspace to cache: {} (db: {})",
545 workspace_root.display(),
546 db_path.display()
547 );
548 self.workspace_cache.insert(
549 workspace_root.clone(),
550 WorkspaceInfo {
551 root: workspace_root.clone(),
552 db_path,
553 store: None,
554 last_used: std::time::Instant::now(),
555 },
556 );
557 }
558
559 let info = self.workspace_cache.get_mut(&workspace_root).unwrap();
561 info.last_used = std::time::Instant::now();
562 Ok(info)
563 }
564
565 fn ensure_workspace_index(&mut self, workspace_root: &Path) -> Result<()> {
567 use crate::daemon;
568
569 let info = self.get_or_create_workspace(workspace_root)?;
570
571 if info.store.is_some() {
572 return Ok(());
573 }
574
575 if !info.db_path.exists() {
576 log::info!(
578 "Index not found for {}. Starting daemon...",
579 info.root.display()
580 );
581 daemon::start(&info.root, &info.db_path, false, true, None)?;
582
583 let max_wait = std::time::Duration::from_secs(60);
585 let start = std::time::Instant::now();
586 let db_path = info.db_path.clone();
587 while !db_path.exists() && start.elapsed() < max_wait {
588 std::thread::sleep(std::time::Duration::from_millis(500));
589 }
590
591 if !db_path.exists() {
592 bail!("Daemon started but index not created within 60 seconds");
593 }
594 }
595
596 let info = self.workspace_cache.get_mut(workspace_root).unwrap();
598 info.store = Some(IndexStore::open(&info.db_path)?);
599 Ok(())
600 }
601
602 fn workspace_for_file(&self, file_path: Option<&str>) -> PathBuf {
604 if let Some(path) = file_path {
605 let path = PathBuf::from(path);
606 if let Some(workspace) = self.infer_workspace(&path) {
607 return workspace;
608 }
609 }
610 self.default_workspace.clone()
611 }
612
613 fn get_store_for_workspace(&mut self, workspace_root: &Path) -> Result<&IndexStore> {
615 self.ensure_workspace_index(workspace_root)?;
616 let info = self.workspace_cache.get(workspace_root).unwrap();
617 info.store
618 .as_ref()
619 .ok_or_else(|| anyhow::anyhow!("Store not initialized"))
620 }
621
622 fn tool_symbols(&mut self, args: &Value) -> Result<ToolResult> {
625 let name = args.get("name").and_then(|v| v.as_str());
626 let kind = args.get("kind").and_then(|v| v.as_str());
627 let file = args.get("file").and_then(|v| v.as_str());
628 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
629
630 let workspace = self.workspace_for_file(file);
632 let store = self.get_store_for_workspace(&workspace)?;
633
634 let symbols = store.list_symbols(file, kind, name, Some(limit))?;
635
636 if symbols.is_empty() {
637 return Ok(ToolResult::text("No symbols found matching the criteria."));
638 }
639
640 let output = format_symbols(&symbols, &workspace);
641 Ok(ToolResult::text(output))
642 }
643
644 fn tool_symbol(&mut self, args: &Value) -> Result<ToolResult> {
645 let workspace = self.default_workspace.clone();
647 let store = self.get_store_for_workspace(&workspace)?;
648
649 let name = args
650 .get("name")
651 .and_then(|v| v.as_str())
652 .ok_or_else(|| anyhow::anyhow!("Missing 'name' argument"))?;
653
654 let kind = args.get("kind").and_then(|v| v.as_str());
655
656 let symbols = store.list_symbols(None, kind, Some(name), Some(10))?;
657
658 if symbols.is_empty() {
659 return Ok(ToolResult::text(format!(
660 "No symbol found with name '{}'",
661 name
662 )));
663 }
664
665 let output = format_symbols(&symbols, &workspace);
666 Ok(ToolResult::text(output))
667 }
668
669 fn tool_definition(&mut self, args: &Value) -> Result<ToolResult> {
670 let file = args
671 .get("file")
672 .and_then(|v| v.as_str())
673 .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
674 let line = args
675 .get("line")
676 .and_then(|v| v.as_u64())
677 .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
678 let character = args
679 .get("character")
680 .and_then(|v| v.as_u64())
681 .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
682 as usize;
683
684 let workspace = self.workspace_for_file(Some(file));
686 let file_path = self.resolve_path_for_workspace(file, &workspace);
687
688 if let Some(symbol) = self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
690 let output = format_symbol(&symbol, &workspace);
691 return Ok(ToolResult::text(format!("Definition:\n{}", output)));
692 }
693
694 Ok(ToolResult::text(format!(
695 "No symbol found at {}:{}:{}",
696 file, line, character
697 )))
698 }
699
700 fn tool_usages(&mut self, args: &Value) -> Result<ToolResult> {
701 let file = args
702 .get("file")
703 .and_then(|v| v.as_str())
704 .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
705 let line = args
706 .get("line")
707 .and_then(|v| v.as_u64())
708 .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
709 let character = args
710 .get("character")
711 .and_then(|v| v.as_u64())
712 .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
713 as usize;
714 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
715
716 let workspace = self.workspace_for_file(Some(file));
718 let file_path = self.resolve_path_for_workspace(file, &workspace);
719
720 let symbol = match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
722 Some(s) => s,
723 None => {
724 return Ok(ToolResult::text(format!(
725 "No symbol found at {}:{}:{}",
726 file, line, character
727 )));
728 }
729 };
730
731 let store = self.get_store_for_workspace(&workspace)?;
733 let refs = store.references_for_symbol(&symbol.id)?;
734
735 if refs.is_empty() {
736 return Ok(ToolResult::text(format!(
737 "No usages found for '{}'",
738 symbol.name
739 )));
740 }
741
742 let refs: Vec<_> = refs.into_iter().take(limit).collect();
743 let mut output = format!("Usages of '{}' ({} found):\n\n", symbol.name, refs.len());
744 for r in &refs {
745 let rel_path = relative_path_for_workspace(&r.file, &workspace);
746 if let Ok((ref_line, ref_col)) = offset_to_line_col(&r.file, r.start as usize) {
748 output.push_str(&format!(" {}:{}:{}\n", rel_path, ref_line, ref_col));
749 }
750 }
751
752 Ok(ToolResult::text(output))
753 }
754
755 fn tool_implementations(&mut self, args: &Value) -> Result<ToolResult> {
756 let file = args
757 .get("file")
758 .and_then(|v| v.as_str())
759 .ok_or_else(|| anyhow::anyhow!("Missing 'file' argument"))?;
760 let line = args
761 .get("line")
762 .and_then(|v| v.as_u64())
763 .ok_or_else(|| anyhow::anyhow!("Missing 'line' argument"))? as usize;
764 let character = args
765 .get("character")
766 .and_then(|v| v.as_u64())
767 .ok_or_else(|| anyhow::anyhow!("Missing 'character' argument"))?
768 as usize;
769 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
770
771 let workspace = self.workspace_for_file(Some(file));
773 let file_path = self.resolve_path_for_workspace(file, &workspace);
774
775 let symbol = match self.find_symbol_at_in_workspace(&file_path, line, character, &workspace)? {
777 Some(s) => s,
778 None => {
779 return Ok(ToolResult::text(format!(
780 "No symbol found at {}:{}:{}",
781 file, line, character
782 )));
783 }
784 };
785
786 let store = self.get_store_for_workspace(&workspace)?;
788 let edges = store.edges_to(&symbol.id)?;
789 let impl_ids: Vec<String> = edges.into_iter().map(|e| e.src).collect();
790 let mut impls = store.symbols_by_ids(&impl_ids)?;
791
792 if impls.is_empty() {
793 let fallback = store.list_symbols(None, None, Some(&symbol.name), Some(limit))?;
795 if fallback.len() <= 1 {
796 return Ok(ToolResult::text(format!(
797 "No implementations found for '{}'",
798 symbol.name
799 )));
800 }
801 let output = format_symbols(&fallback, &workspace);
802 return Ok(ToolResult::text(format!(
803 "Implementations of '{}' (by name):\n\n{}",
804 symbol.name, output
805 )));
806 }
807
808 impls.truncate(limit);
809 let output = format_symbols(&impls, &workspace);
810 Ok(ToolResult::text(format!(
811 "Implementations of '{}':\n\n{}",
812 symbol.name, output
813 )))
814 }
815
816 fn tool_daemon_status(&mut self) -> Result<ToolResult> {
817 use crate::daemon;
818
819 let mut status = String::new();
820
821 status.push_str("Default Workspace:\n");
823 if let Ok(Some(pid_info)) = daemon::read_pid_file(&self.default_workspace) {
824 if daemon::is_process_running(pid_info.pid) {
825 status.push_str(&format!(
826 " Daemon: running (PID {})\n Version: {}\n Root: {}\n Database: {}\n",
827 pid_info.pid,
828 pid_info.version,
829 self.default_workspace.display(),
830 self.default_db_path.display()
831 ));
832 } else {
833 status.push_str(&format!(
834 " Daemon: not running (stale PID file)\n Root: {}\n Database: {}\n",
835 self.default_workspace.display(),
836 self.default_db_path.display()
837 ));
838 }
839 } else {
840 status.push_str(&format!(
841 " Daemon: not running\n Root: {}\n Database: {}\n",
842 self.default_workspace.display(),
843 self.default_db_path.display()
844 ));
845 }
846
847 if !self.workspace_cache.is_empty() {
849 status.push_str(&format!(
850 "\nCached Workspaces ({}/{}):\n",
851 self.workspace_cache.len(),
852 MAX_CACHED_WORKSPACES
853 ));
854 for (root, info) in &self.workspace_cache {
855 let daemon_status = if let Ok(Some(pid_info)) = daemon::read_pid_file(root) {
856 if daemon::is_process_running(pid_info.pid) {
857 format!("running (PID {})", pid_info.pid)
858 } else {
859 "not running".to_string()
860 }
861 } else {
862 "not running".to_string()
863 };
864 let index_status = if info.store.is_some() {
865 "loaded"
866 } else if info.db_path.exists() {
867 "available"
868 } else {
869 "not indexed"
870 };
871 status.push_str(&format!(
872 " {}\n Daemon: {}, Index: {}\n",
873 root.display(),
874 daemon_status,
875 index_status
876 ));
877 }
878 }
879
880 Ok(ToolResult::text(status))
881 }
882
883 fn tool_duplicates(&mut self, args: &Value) -> Result<ToolResult> {
884 let kind = args.get("kind").and_then(|v| v.as_str());
885 let min_count = args.get("min_count").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
886 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
887
888 let workspace = self.default_workspace.clone();
890 let store = self.get_store_for_workspace(&workspace)?;
891
892 let groups = store.find_duplicate_groups(min_count, kind, None)?;
893
894 if groups.is_empty() {
895 return Ok(ToolResult::text("No duplicate code found."));
896 }
897
898 let groups: Vec<_> = groups.into_iter().take(limit).collect();
899 let output = format_duplicate_groups(&groups, &workspace);
900 Ok(ToolResult::text(output))
901 }
902
903 fn resolve_path_for_workspace(&self, path: &str, workspace: &Path) -> PathBuf {
906 let p = PathBuf::from(path);
907 if p.is_absolute() {
908 p
909 } else {
910 workspace.join(p)
911 }
912 }
913
914 fn find_symbol_at_in_workspace(
915 &mut self,
916 file: &Path,
917 line: usize,
918 character: usize,
919 workspace: &Path,
920 ) -> Result<Option<SymbolRecord>> {
921 let store = self.get_store_for_workspace(workspace)?;
922 let file_str = file.to_string_lossy().to_string();
923
924 let content = std::fs::read_to_string(file)?;
926 let mut offset: i64 = 0;
927 for (i, l) in content.lines().enumerate() {
928 if i + 1 == line {
929 offset += character.saturating_sub(1) as i64;
930 break;
931 }
932 offset += l.len() as i64 + 1; }
934
935 let symbols = store.list_symbols(Some(&file_str), None, None, None)?;
937
938 let mut best: Option<SymbolRecord> = None;
940 for sym in symbols {
941 if sym.start <= offset && offset < sym.end {
942 let span = sym.end - sym.start;
943 if best
944 .as_ref()
945 .map(|b| span < (b.end - b.start))
946 .unwrap_or(true)
947 {
948 best = Some(sym);
949 }
950 }
951 }
952
953 Ok(best)
954 }
955}
956
957fn relative_path_for_workspace(path: &str, workspace: &Path) -> String {
961 let p = PathBuf::from(path);
962 p.strip_prefix(workspace)
963 .map(|p| p.to_string_lossy().to_string())
964 .unwrap_or_else(|_| path.to_string())
965}
966
967fn format_symbols(symbols: &[SymbolRecord], workspace_root: &Path) -> String {
968 let mut output = String::new();
969 for sym in symbols {
970 output.push_str(&format_symbol(sym, workspace_root));
971 output.push('\n');
972 }
973 output
974}
975
976fn format_symbol(sym: &SymbolRecord, workspace_root: &Path) -> String {
977 let rel_path = PathBuf::from(&sym.file)
978 .strip_prefix(workspace_root)
979 .map(|p| p.to_string_lossy().to_string())
980 .unwrap_or_else(|_| sym.file.clone());
981
982 let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize) {
984 format!("{}:{}:{}", rel_path, line, col)
985 } else {
986 format!("{}:offset:{}", rel_path, sym.start)
987 };
988
989 let mut parts = vec![format!("{:<10} {:<30} {}", sym.kind, sym.name, location)];
990
991 if let Some(ref vis) = sym.visibility {
992 parts.push(format!(" visibility: {}", vis));
993 }
994 if let Some(ref container) = sym.container {
995 parts.push(format!(" container: {}", container));
996 }
997
998 parts.join("\n")
999}
1000
1001fn format_duplicate_groups(groups: &[DuplicateGroup], workspace_root: &Path) -> String {
1002 let total_groups = groups.len();
1003 let total_duplicates: usize = groups.iter().map(|g| g.symbols.len()).sum();
1004
1005 let mut output = format!(
1006 "Found {} duplicate groups ({} total symbols)\n\n",
1007 total_groups, total_duplicates
1008 );
1009
1010 for (i, group) in groups.iter().enumerate() {
1011 let short_hash = &group.content_hash[..8.min(group.content_hash.len())];
1012 output.push_str(&format!(
1013 "Group {} ({} duplicates, hash: {}):\n",
1014 i + 1,
1015 group.symbols.len(),
1016 short_hash
1017 ));
1018
1019 for sym in &group.symbols {
1020 let rel_path = PathBuf::from(&sym.file)
1021 .strip_prefix(workspace_root)
1022 .map(|p| p.to_string_lossy().to_string())
1023 .unwrap_or_else(|_| sym.file.clone());
1024
1025 let location = if let Ok((line, col)) = offset_to_line_col(&sym.file, sym.start as usize)
1026 {
1027 format!("{}:{}:{}", rel_path, line, col)
1028 } else {
1029 format!("{}:offset:{}", rel_path, sym.start)
1030 };
1031
1032 let container = sym
1033 .container
1034 .as_ref()
1035 .map(|c| format!(" in {}", c))
1036 .unwrap_or_default();
1037
1038 output.push_str(&format!(
1039 " {:<10} {:<30} {}{}\n",
1040 sym.kind, sym.name, location, container
1041 ));
1042 }
1043 output.push('\n');
1044 }
1045
1046 output
1047}
1048
1049fn offset_to_line_col(file_path: &str, offset: usize) -> Result<(usize, usize)> {
1051 let content = std::fs::read(file_path)?;
1052 let mut line = 1;
1053 let mut col = 1;
1054 for (i, &b) in content.iter().enumerate() {
1055 if i == offset {
1056 return Ok((line, col));
1057 }
1058 if b == b'\n' {
1059 line += 1;
1060 col = 1;
1061 } else {
1062 col += 1;
1063 }
1064 }
1065 if offset == content.len() {
1066 Ok((line, col))
1067 } else {
1068 anyhow::bail!("offset out of bounds")
1069 }
1070}
1071
1072pub fn run_server(workspace_root: &Path, db_path: &Path) -> Result<()> {
1074 let mut server = McpServer::new(workspace_root.to_path_buf(), db_path.to_path_buf());
1075 server.run()
1076}