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