Skip to main content

gitcortex_mcp/mcp/
tools.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use gitcortex_core::{schema::NodeKind, store::GraphStore};
5use gitcortex_store::kuzu::KuzuGraphStore;
6use rmcp::{
7    handler::server::wrapper::Parameters,
8    model::{
9        CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
10        PaginatedRequestParams, PromptMessage, PromptMessageRole,
11    },
12    prompt, prompt_handler, prompt_router,
13    service::RequestContext,
14    tool, tool_handler, tool_router, RoleServer,
15};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use serde_json::json;
19
20// ── Parameter types ───────────────────────────────────────────────────────────
21
22#[derive(Debug, Deserialize, JsonSchema)]
23pub struct LookupSymbolParams {
24    /// Symbol name to search for (unqualified).
25    pub name: String,
26    /// When true, matches any symbol whose name *contains* `name` (substring).
27    /// When false (default), exact match only.
28    pub fuzzy: Option<bool>,
29    /// Branch name (defaults to "main" if omitted).
30    pub branch: Option<String>,
31}
32
33#[derive(Debug, Deserialize, JsonSchema)]
34pub struct FindCallersParams {
35    /// Name of the function/method to find callers of.
36    pub function_name: String,
37    /// How many hops to walk up the call graph (1–5, default 1).
38    /// depth=1 returns direct callers only. depth=3 walks three levels.
39    pub depth: Option<u8>,
40    pub branch: Option<String>,
41}
42
43#[derive(Debug, Deserialize, JsonSchema)]
44pub struct SymbolContextParams {
45    /// Symbol name to look up (unqualified).
46    pub name: String,
47    /// Branch name (defaults to current branch if omitted).
48    pub branch: Option<String>,
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
52pub struct ListDefinitionsParams {
53    /// Repo-relative path to a source file.
54    pub file: String,
55    pub branch: Option<String>,
56}
57
58#[derive(Debug, Deserialize, JsonSchema)]
59pub struct BranchDiffParams {
60    pub from_branch: String,
61    pub to_branch: String,
62}
63
64#[derive(Debug, Deserialize, JsonSchema)]
65pub struct DetectChangesParams {
66    /// Branch to query (defaults to "main" if omitted).
67    pub branch: Option<String>,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct FindCalleesParams {
72    /// Name of the function/method to trace callees of.
73    pub function_name: String,
74    /// How many hops to walk forward in the call graph (1–5, default 1).
75    pub depth: Option<u8>,
76    pub branch: Option<String>,
77}
78
79#[derive(Debug, Deserialize, JsonSchema)]
80pub struct FindImplementorsParams {
81    /// Trait, interface, or abstract class name to find implementors of.
82    pub trait_name: String,
83    pub branch: Option<String>,
84}
85
86#[derive(Debug, Deserialize, JsonSchema)]
87pub struct TracePathParams {
88    /// Starting function/method name.
89    pub from: String,
90    /// Target function/method name.
91    pub to: String,
92    pub branch: Option<String>,
93}
94
95#[derive(Debug, Deserialize, JsonSchema)]
96pub struct ListSymbolsInRangeParams {
97    /// Repo-relative path to a source file.
98    pub file: String,
99    /// Start line of the range (1-indexed, inclusive).
100    pub start_line: u32,
101    /// End line of the range (1-indexed, inclusive).
102    pub end_line: u32,
103    pub branch: Option<String>,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct FindUnusedSymbolsParams {
108    /// Optional NodeKind filter: "function", "method", "struct", etc.
109    pub kind: Option<String>,
110    pub branch: Option<String>,
111}
112
113#[derive(Debug, Deserialize, JsonSchema)]
114pub struct GetSubgraphParams {
115    /// Seed symbol name (unqualified).
116    pub seed_name: String,
117    /// How many hops to expand from the seed (1–5, default 2).
118    pub depth: Option<u8>,
119    /// Direction: "in" (callers/ancestors), "out" (callees/descendants), "both" (default).
120    pub direction: Option<String>,
121    pub branch: Option<String>,
122}
123
124#[derive(Debug, Deserialize, JsonSchema)]
125pub struct WikiSymbolParams {
126    /// Symbol to summarise (unqualified name).
127    pub name: String,
128    pub branch: Option<String>,
129}
130
131#[derive(Debug, Deserialize, JsonSchema)]
132pub struct SearchCodeParams {
133    /// Free-text query — substring matched against `name` and `qualified_name`.
134    pub query: String,
135    /// Max results (default 25, capped at 200).
136    pub limit: Option<usize>,
137    pub branch: Option<String>,
138}
139
140#[derive(Debug, Deserialize, JsonSchema)]
141pub struct StartTourParams {
142    /// Optional seed symbol — when given, the tour walks outward from it
143    /// along the call graph. When omitted, picks the highest-centrality
144    /// entry points across the repo.
145    pub seed: Option<String>,
146    /// How many steps in the tour (default 12, capped at 50).
147    pub limit: Option<usize>,
148    pub branch: Option<String>,
149}
150
151// ── Server ────────────────────────────────────────────────────────────────────
152
153/// The MCP server handler. One shared `KuzuGraphStore` wrapped in `Arc<Mutex>`
154/// so all handler calls can share state safely.
155#[derive(Clone)]
156pub struct GitCortexServer {
157    store: Arc<std::sync::Mutex<KuzuGraphStore>>,
158    repo_root: PathBuf,
159    default_branch: String,
160}
161
162impl GitCortexServer {
163    pub fn new(repo_root: &Path) -> anyhow::Result<Self> {
164        let store = KuzuGraphStore::open(repo_root)?;
165        let default_branch = detect_current_branch(repo_root).unwrap_or_else(|| "main".into());
166        Ok(Self {
167            store: Arc::new(std::sync::Mutex::new(store)),
168            repo_root: repo_root.to_owned(),
169            default_branch,
170        })
171    }
172}
173
174fn detect_current_branch(repo_root: &Path) -> Option<String> {
175    let out = std::process::Command::new("git")
176        .args(["symbolic-ref", "--short", "HEAD"])
177        .current_dir(repo_root)
178        .output()
179        .ok()?;
180    if out.status.success() {
181        let s = String::from_utf8(out.stdout).ok()?;
182        let b = s.trim().to_owned();
183        if b.is_empty() {
184            None
185        } else {
186            Some(b)
187        }
188    } else {
189        None
190    }
191}
192
193// ── Tool implementations ──────────────────────────────────────────────────────
194
195#[tool_router]
196impl GitCortexServer {
197    /// Look up all nodes (functions, structs, traits, etc.) by name.
198    #[tool(
199        description = "Look up nodes in the code knowledge graph by name. Set fuzzy=true for substring matching (e.g. 'auth' finds 'validate_auth', 'auth_middleware'). Default is exact match."
200    )]
201    fn lookup_symbol(&self, Parameters(p): Parameters<LookupSymbolParams>) -> CallToolResult {
202        let branch = p
203            .branch
204            .as_deref()
205            .unwrap_or(&self.default_branch)
206            .to_owned();
207        let fuzzy = p.fuzzy.unwrap_or(false);
208        let store = match self.store.lock() {
209            Ok(g) => g,
210            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
211        };
212        match store.lookup_symbol(&branch, &p.name, fuzzy) {
213            Ok(nodes) => {
214                let items: Vec<_> = nodes
215                    .iter()
216                    .map(|n| {
217                        json!({
218                            "id": n.id.as_str(),
219                            "kind": n.kind.to_string(),
220                            "name": n.name,
221                            "qualified_name": n.qualified_name,
222                            "file": n.file.display().to_string(),
223                            "start_line": n.span.start_line,
224                            "end_line": n.span.end_line,
225                            "visibility": format!("{:?}", n.metadata.visibility),
226                            "is_async": n.metadata.is_async,
227                            "is_unsafe": n.metadata.is_unsafe,
228                        })
229                    })
230                    .collect();
231                CallToolResult::structured(json!(items))
232            }
233            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
234        }
235    }
236
237    /// Find all callers of a function or method, with optional multi-hop depth.
238    #[tool(
239        description = "Find all functions/methods that call the named function. \
240        Use depth=1 (default) for direct callers only, or depth=2..5 to walk the call graph \
241        multiple hops. Returns callers grouped by hop distance with a risk level."
242    )]
243    fn find_callers(&self, Parameters(p): Parameters<FindCallersParams>) -> CallToolResult {
244        let branch = p
245            .branch
246            .as_deref()
247            .unwrap_or(&self.default_branch)
248            .to_owned();
249        let depth = p.depth.unwrap_or(1).max(1);
250        let store = match self.store.lock() {
251            Ok(g) => g,
252            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
253        };
254
255        if depth == 1 {
256            match store.find_callers(&branch, &p.function_name) {
257                Ok(nodes) => {
258                    let items: Vec<_> = nodes
259                        .iter()
260                        .map(|n| {
261                            json!({
262                                "hop": 1,
263                                "kind": n.kind.to_string(),
264                                "name": n.name,
265                                "qualified_name": n.qualified_name,
266                                "file": n.file.display().to_string(),
267                                "start_line": n.span.start_line,
268                            })
269                        })
270                        .collect();
271                    CallToolResult::structured(json!({
272                        "function": p.function_name,
273                        "depth": 1,
274                        "risk_level": match items.len() {
275                            0..=2 => "LOW",
276                            3..=10 => "MEDIUM",
277                            11..=30 => "HIGH",
278                            _ => "CRITICAL",
279                        },
280                        "callers": items,
281                    }))
282                }
283                Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
284            }
285        } else {
286            match store.find_callers_deep(&branch, &p.function_name, depth) {
287                Ok(result) => {
288                    let hops: Vec<_> = result
289                        .hops
290                        .iter()
291                        .enumerate()
292                        .map(|(i, nodes)| {
293                            let callers: Vec<_> = nodes
294                                .iter()
295                                .map(|n| {
296                                    json!({
297                                        "kind": n.kind.to_string(),
298                                        "name": n.name,
299                                        "qualified_name": n.qualified_name,
300                                        "file": n.file.display().to_string(),
301                                        "start_line": n.span.start_line,
302                                    })
303                                })
304                                .collect();
305                            json!({ "hop": i + 1, "callers": callers })
306                        })
307                        .collect();
308                    CallToolResult::structured(json!({
309                        "function": p.function_name,
310                        "depth": depth,
311                        "risk_level": result.risk_level,
312                        "hops": hops,
313                    }))
314                }
315                Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
316            }
317        }
318    }
319
320    /// Get a 360° view of a symbol: definition, callers, callees, and type usages.
321    #[tool(
322        description = "Get a complete picture of a symbol in one call: where it's defined, \
323        what calls it (callers), what it calls (callees), and which code references it as a type. \
324        Use this instead of chaining lookup_symbol + find_callers separately."
325    )]
326    fn symbol_context(&self, Parameters(p): Parameters<SymbolContextParams>) -> CallToolResult {
327        let branch = p
328            .branch
329            .as_deref()
330            .unwrap_or(&self.default_branch)
331            .to_owned();
332        let store = match self.store.lock() {
333            Ok(g) => g,
334            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
335        };
336        match store.symbol_context(&branch, &p.name) {
337            Ok(ctx) => {
338                let node_json = |n: &gitcortex_core::graph::Node| {
339                    json!({
340                        "kind": n.kind.to_string(),
341                        "name": n.name,
342                        "qualified_name": n.qualified_name,
343                        "file": n.file.display().to_string(),
344                        "start_line": n.span.start_line,
345                    })
346                };
347                CallToolResult::structured(json!({
348                    "definition": {
349                        "kind": ctx.definition.kind.to_string(),
350                        "name": ctx.definition.name,
351                        "qualified_name": ctx.definition.qualified_name,
352                        "file": ctx.definition.file.display().to_string(),
353                        "start_line": ctx.definition.span.start_line,
354                        "end_line": ctx.definition.span.end_line,
355                        "visibility": format!("{:?}", ctx.definition.metadata.visibility),
356                        "is_async": ctx.definition.metadata.is_async,
357                    },
358                    "callers": ctx.callers.iter().map(node_json).collect::<Vec<_>>(),
359                    "callees": ctx.callees.iter().map(node_json).collect::<Vec<_>>(),
360                    "used_by": ctx.used_by.iter().map(node_json).collect::<Vec<_>>(),
361                }))
362            }
363            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
364        }
365    }
366
367    /// List all symbols defined in a source file, ordered by line number.
368    #[tool(
369        description = "List all functions, structs, traits, and other definitions in a source file, ordered by line number."
370    )]
371    fn list_definitions(&self, Parameters(p): Parameters<ListDefinitionsParams>) -> CallToolResult {
372        let branch = p
373            .branch
374            .as_deref()
375            .unwrap_or(&self.default_branch)
376            .to_owned();
377        let store = match self.store.lock() {
378            Ok(g) => g,
379            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
380        };
381        match store.list_definitions(&branch, Path::new(&p.file)) {
382            Ok(nodes) => {
383                let items: Vec<_> = nodes
384                    .iter()
385                    .map(|n| {
386                        json!({
387                            "kind": n.kind.to_string(),
388                            "name": n.name,
389                            "qualified_name": n.qualified_name,
390                            "start_line": n.span.start_line,
391                            "end_line": n.span.end_line,
392                            "loc": n.metadata.loc,
393                            "visibility": format!("{:?}", n.metadata.visibility),
394                            "is_async": n.metadata.is_async,
395                        })
396                    })
397                    .collect();
398                CallToolResult::structured(json!(items))
399            }
400            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
401        }
402    }
403
404    /// Compute the graph diff between two branches.
405    #[tool(
406        description = "Show what nodes were added or removed between two branches. Useful for understanding what changed in a feature branch vs main."
407    )]
408    fn branch_diff_graph(&self, Parameters(p): Parameters<BranchDiffParams>) -> CallToolResult {
409        let store = match self.store.lock() {
410            Ok(g) => g,
411            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
412        };
413        match store.branch_diff(&p.from_branch, &p.to_branch) {
414            Ok(diff) => {
415                let added: Vec<_> = diff
416                    .added_nodes
417                    .iter()
418                    .map(|n| {
419                        json!({
420                            "kind": n.kind.to_string(),
421                            "name": n.name,
422                            "file": n.file.display().to_string(),
423                            "start_line": n.span.start_line,
424                        })
425                    })
426                    .collect();
427
428                // Resolve removed node IDs to full node objects from the from_branch.
429                let from_nodes = store.list_all_nodes(&p.from_branch).unwrap_or_default();
430                let from_map: std::collections::HashMap<_, _> =
431                    from_nodes.iter().map(|n| (n.id.clone(), n)).collect();
432                let removed: Vec<_> = diff
433                    .removed_node_ids
434                    .iter()
435                    .filter_map(|id| from_map.get(id))
436                    .map(|n| {
437                        json!({
438                            "kind": n.kind.to_string(),
439                            "name": n.name,
440                            "file": n.file.display().to_string(),
441                            "start_line": n.span.start_line,
442                        })
443                    })
444                    .collect();
445
446                CallToolResult::structured(json!({
447                    "from": p.from_branch,
448                    "to": p.to_branch,
449                    "added_nodes": added,
450                    "removed_nodes": removed,
451                }))
452            }
453            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
454        }
455    }
456
457    /// Detect which indexed symbols are affected by current staged (or HEAD) changes.
458    #[tool(
459        description = "Map the current git diff (staged changes, or HEAD diff if nothing is staged) \
460        to the indexed symbol graph. Returns which functions/structs were changed, their direct callers, \
461        and a risk level. Use this before committing to understand blast radius automatically."
462    )]
463    fn detect_changes(&self, Parameters(p): Parameters<DetectChangesParams>) -> CallToolResult {
464        let branch = p
465            .branch
466            .as_deref()
467            .unwrap_or(&self.default_branch)
468            .to_owned();
469
470        let diff_text = run_git_diff(&self.repo_root, &["diff", "--staged"])
471            .filter(|s| !s.trim().is_empty())
472            .or_else(|| run_git_diff(&self.repo_root, &["diff", "HEAD"]))
473            .unwrap_or_default();
474
475        if diff_text.trim().is_empty() {
476            return CallToolResult::success(vec![Content::text(
477                "No staged or unstaged changes detected.",
478            )]);
479        }
480
481        let hunks = parse_diff_hunks(&diff_text);
482        let store = match self.store.lock() {
483            Ok(g) => g,
484            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
485        };
486
487        let mut changed_symbols: Vec<serde_json::Value> = Vec::new();
488        let mut total_affected: usize = 0;
489
490        for (file_path, ranges) in &hunks {
491            let path = PathBuf::from(file_path);
492            let definitions = match store.list_definitions(&branch, &path) {
493                Ok(d) => d,
494                Err(_) => continue,
495            };
496            for node in &definitions {
497                let overlaps = ranges
498                    .iter()
499                    .any(|(s, e)| node.span.start_line <= *e && node.span.end_line >= *s);
500                if !overlaps {
501                    continue;
502                }
503                let callers = store.find_callers(&branch, &node.name).unwrap_or_default();
504                let caller_names: Vec<&str> = callers.iter().map(|c| c.name.as_str()).collect();
505                total_affected += 1 + caller_names.len();
506                changed_symbols.push(json!({
507                    "kind": node.kind.to_string(),
508                    "name": node.name,
509                    "file": file_path,
510                    "start_line": node.span.start_line,
511                    "end_line": node.span.end_line,
512                    "callers": caller_names,
513                }));
514            }
515        }
516
517        if changed_symbols.is_empty() {
518            return CallToolResult::success(vec![Content::text(
519                "Changed lines do not overlap with any indexed symbols.",
520            )]);
521        }
522
523        let risk_level = match total_affected {
524            0..=5 => "LOW",
525            6..=20 => "MEDIUM",
526            21..=50 => "HIGH",
527            _ => "CRITICAL",
528        };
529
530        CallToolResult::structured(json!({
531            "risk_level": risk_level,
532            "total_affected": total_affected,
533            "changed_symbols": changed_symbols,
534        }))
535    }
536
537    /// Find all callees of a function/method, tracing forward through the call graph.
538    #[tool(
539        description = "Find all functions/methods that the named function calls. \
540        Inverse of find_callers — traces forward (downstream). Use depth=1..5 to walk multiple hops. \
541        Returns callees grouped by hop distance."
542    )]
543    fn find_callees(&self, Parameters(p): Parameters<FindCalleesParams>) -> CallToolResult {
544        let branch = p
545            .branch
546            .as_deref()
547            .unwrap_or(&self.default_branch)
548            .to_owned();
549        let depth = p.depth.unwrap_or(1).max(1);
550        let store = match self.store.lock() {
551            Ok(g) => g,
552            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
553        };
554        match store.find_callees(&branch, &p.function_name, depth) {
555            Ok(result) => {
556                let hops: Vec<_> = result
557                    .hops
558                    .iter()
559                    .enumerate()
560                    .map(|(i, nodes)| {
561                        let callees: Vec<_> = nodes
562                            .iter()
563                            .map(|n| {
564                                json!({
565                                    "kind": n.kind.to_string(),
566                                    "name": n.name,
567                                    "qualified_name": n.qualified_name,
568                                    "file": n.file.display().to_string(),
569                                    "start_line": n.span.start_line,
570                                })
571                            })
572                            .collect();
573                        json!({ "hop": i + 1, "callees": callees })
574                    })
575                    .collect();
576                CallToolResult::structured(json!({
577                    "function": p.function_name,
578                    "depth": depth,
579                    "hops": hops,
580                }))
581            }
582            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
583        }
584    }
585
586    /// Find all structs/classes that implement a trait or interface.
587    #[tool(
588        description = "Find all concrete types (structs, classes) that implement or inherit the named \
589        trait or interface. Works for Rust traits, Java/TypeScript interfaces, and Go structural types."
590    )]
591    fn find_implementors(
592        &self,
593        Parameters(p): Parameters<FindImplementorsParams>,
594    ) -> CallToolResult {
595        let branch = p
596            .branch
597            .as_deref()
598            .unwrap_or(&self.default_branch)
599            .to_owned();
600        let store = match self.store.lock() {
601            Ok(g) => g,
602            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
603        };
604        match store.find_implementors(&branch, &p.trait_name) {
605            Ok(nodes) => {
606                let items: Vec<_> = nodes
607                    .iter()
608                    .map(|n| {
609                        json!({
610                            "kind": n.kind.to_string(),
611                            "name": n.name,
612                            "qualified_name": n.qualified_name,
613                            "file": n.file.display().to_string(),
614                            "start_line": n.span.start_line,
615                        })
616                    })
617                    .collect();
618                CallToolResult::structured(json!({
619                    "trait": p.trait_name,
620                    "implementors": items,
621                }))
622            }
623            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
624        }
625    }
626
627    /// Find a call path between two symbols in the codebase.
628    #[tool(
629        description = "Find a call path from one function to another. Returns the shortest chain of \
630        calls connecting `from` to `to`. Returns an empty array if no path exists within 6 hops. \
631        Most useful for debugging 'how can A reach B?' questions."
632    )]
633    fn trace_path(&self, Parameters(p): Parameters<TracePathParams>) -> CallToolResult {
634        let branch = p
635            .branch
636            .as_deref()
637            .unwrap_or(&self.default_branch)
638            .to_owned();
639        let store = match self.store.lock() {
640            Ok(g) => g,
641            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
642        };
643        match store.trace_path(&branch, &p.from, &p.to) {
644            Ok(path) => {
645                let nodes: Vec<_> = path
646                    .iter()
647                    .map(|n| {
648                        json!({
649                            "kind": n.kind.to_string(),
650                            "name": n.name,
651                            "file": n.file.display().to_string(),
652                            "start_line": n.span.start_line,
653                        })
654                    })
655                    .collect();
656                CallToolResult::structured(json!({
657                    "from": p.from,
658                    "to": p.to,
659                    "found": !path.is_empty(),
660                    "path": nodes,
661                }))
662            }
663            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
664        }
665    }
666
667    /// Find all indexed symbols that overlap a line range in a file.
668    #[tool(
669        description = "List all symbols (functions, structs, etc.) in a source file whose span \
670        overlaps the given line range. Use this to map a stack trace, diff hunk, or grep result \
671        to the symbols responsible."
672    )]
673    fn list_symbols_in_range(
674        &self,
675        Parameters(p): Parameters<ListSymbolsInRangeParams>,
676    ) -> CallToolResult {
677        let branch = p
678            .branch
679            .as_deref()
680            .unwrap_or(&self.default_branch)
681            .to_owned();
682        let store = match self.store.lock() {
683            Ok(g) => g,
684            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
685        };
686        let path = Path::new(&p.file);
687        match store.list_symbols_in_range(&branch, path, p.start_line, p.end_line) {
688            Ok(nodes) => {
689                let items: Vec<_> = nodes
690                    .iter()
691                    .map(|n| {
692                        json!({
693                            "kind": n.kind.to_string(),
694                            "name": n.name,
695                            "qualified_name": n.qualified_name,
696                            "start_line": n.span.start_line,
697                            "end_line": n.span.end_line,
698                            "loc": n.metadata.loc,
699                        })
700                    })
701                    .collect();
702                CallToolResult::structured(json!({
703                    "file": p.file,
704                    "range": { "start": p.start_line, "end": p.end_line },
705                    "symbols": items,
706                }))
707            }
708            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
709        }
710    }
711
712    /// Find symbols with no callers or type references — potential dead code.
713    #[tool(
714        description = "Find symbols that are never called or used as a type anywhere in the indexed \
715        codebase. Useful for identifying dead code, safe-to-rename candidates, or refactoring targets. \
716        Pass kind='function' to restrict to functions only."
717    )]
718    fn find_unused_symbols(
719        &self,
720        Parameters(p): Parameters<FindUnusedSymbolsParams>,
721    ) -> CallToolResult {
722        let branch = p
723            .branch
724            .as_deref()
725            .unwrap_or(&self.default_branch)
726            .to_owned();
727        let kind = p.kind.as_deref().and_then(|k| match k {
728            "function" => Some(NodeKind::Function),
729            "method" => Some(NodeKind::Method),
730            "struct" => Some(NodeKind::Struct),
731            "trait" => Some(NodeKind::Trait),
732            "interface" => Some(NodeKind::Interface),
733            "enum" => Some(NodeKind::Enum),
734            "constant" => Some(NodeKind::Constant),
735            _ => None,
736        });
737        let store = match self.store.lock() {
738            Ok(g) => g,
739            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
740        };
741        match store.find_unused_symbols(&branch, kind) {
742            Ok(nodes) => {
743                let items: Vec<_> = nodes
744                    .iter()
745                    .map(|n| {
746                        json!({
747                            "kind": n.kind.to_string(),
748                            "name": n.name,
749                            "qualified_name": n.qualified_name,
750                            "file": n.file.display().to_string(),
751                            "start_line": n.span.start_line,
752                            "visibility": format!("{:?}", n.metadata.visibility),
753                        })
754                    })
755                    .collect();
756                CallToolResult::structured(json!({
757                    "branch": branch,
758                    "unused_symbols": items,
759                    "count": nodes.len(),
760                }))
761            }
762            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
763        }
764    }
765
766    /// Return a neighbourhood subgraph around a seed symbol.
767    #[tool(
768        description = "Return the subgraph centred on a seed symbol — all nodes and edges reachable \
769        within `depth` hops. Use direction='out' for downstream only, 'in' for upstream only, \
770        or 'both' (default) for both directions. Ideal for architecture rendering and impact analysis."
771    )]
772    fn get_subgraph(&self, Parameters(p): Parameters<GetSubgraphParams>) -> CallToolResult {
773        let branch = p
774            .branch
775            .as_deref()
776            .unwrap_or(&self.default_branch)
777            .to_owned();
778        let depth = p.depth.unwrap_or(2);
779        let direction = p.direction.as_deref().unwrap_or("both").to_owned();
780        let store = match self.store.lock() {
781            Ok(g) => g,
782            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
783        };
784        match store.get_subgraph(&branch, &p.seed_name, depth, &direction) {
785            Ok(sg) => {
786                let nodes: Vec<_> = sg
787                    .nodes
788                    .iter()
789                    .map(|n| {
790                        json!({
791                            "id": n.id.as_str(),
792                            "kind": n.kind.to_string(),
793                            "name": n.name,
794                            "file": n.file.display().to_string(),
795                            "start_line": n.span.start_line,
796                        })
797                    })
798                    .collect();
799                let edges: Vec<_> = sg
800                    .edges
801                    .iter()
802                    .map(|e| {
803                        json!({
804                            "src": e.src.as_str(),
805                            "dst": e.dst.as_str(),
806                            "kind": e.kind.to_string(),
807                        })
808                    })
809                    .collect();
810                CallToolResult::structured(json!({
811                    "seed": p.seed_name,
812                    "depth": depth,
813                    "direction": direction,
814                    "node_count": sg.nodes.len(),
815                    "edge_count": sg.edges.len(),
816                    "nodes": nodes,
817                    "edges": edges,
818                }))
819            }
820            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
821        }
822    }
823
824    /// Render a wiki-style markdown summary for a symbol.
825    #[tool(
826        description = "Render a wiki page for a symbol — definition signature, doc-comment, callers, \
827        callees, and used-by, formatted as markdown. Combines `lookup_symbol`, `find_callers`, and \
828        `find_callees` in one structured view ready to paste into a README or PR description."
829    )]
830    fn wiki_symbol(&self, Parameters(p): Parameters<WikiSymbolParams>) -> CallToolResult {
831        let branch = p
832            .branch
833            .as_deref()
834            .unwrap_or(&self.default_branch)
835            .to_owned();
836        let store = match self.store.lock() {
837            Ok(g) => g,
838            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
839        };
840        match super::wiki::render_symbol(&*store, &branch, &p.name) {
841            Ok(markdown) => CallToolResult::structured(json!({
842                "symbol": p.name,
843                "branch": branch,
844                "markdown": markdown,
845            })),
846            Err(e) => CallToolResult::error(vec![Content::text(format!("wiki failed: {e}"))]),
847        }
848    }
849
850    /// Search the graph by name + qualified-name with deterministic ranking.
851    #[tool(
852        description = "Fuzzy search the code knowledge graph. Matches the query against both the \
853        unqualified `name` and full `qualified_name`, ranks by exactness (exact > prefix > \
854        substring), and applies kind boosts (functions/structs rank above generic nodes). \
855        Returns up to `limit` hits with scores."
856    )]
857    fn search_code(&self, Parameters(p): Parameters<SearchCodeParams>) -> CallToolResult {
858        let branch = p
859            .branch
860            .as_deref()
861            .unwrap_or(&self.default_branch)
862            .to_owned();
863        let store = match self.store.lock() {
864            Ok(g) => g,
865            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
866        };
867        match super::search::search(&*store, &branch, &p.query, p.limit) {
868            Ok(hits) => CallToolResult::structured(json!({
869                "query": p.query,
870                "branch": branch,
871                "count": hits.len(),
872                "hits": hits,
873            })),
874            Err(e) => CallToolResult::error(vec![Content::text(format!("search failed: {e}"))]),
875        }
876    }
877
878    /// Generate a guided tour through the repo's important symbols.
879    #[tool(
880        description = "Generate a guided tour through the codebase. Without a seed, picks the \
881        highest-centrality public functions/structs to give a new contributor an entry path. \
882        With a seed, BFS-walks outward from it along call edges. Returns ordered tour steps \
883        with rationale per step and a rendered markdown plan."
884    )]
885    fn start_tour(&self, Parameters(p): Parameters<StartTourParams>) -> CallToolResult {
886        let branch = p
887            .branch
888            .as_deref()
889            .unwrap_or(&self.default_branch)
890            .to_owned();
891        let store = match self.store.lock() {
892            Ok(g) => g,
893            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
894        };
895        match super::tour::generate(&*store, &branch, p.seed.as_deref(), p.limit) {
896            Ok(tour) => {
897                let markdown = super::tour::render_markdown(&tour);
898                CallToolResult::structured(json!({
899                    "branch": tour.branch,
900                    "seed": tour.seed,
901                    "steps": tour.steps,
902                    "markdown": markdown,
903                }))
904            }
905            Err(e) => CallToolResult::error(vec![Content::text(format!("tour failed: {e}"))]),
906        }
907    }
908}
909
910// ── Prompt parameter types ────────────────────────────────────────────────────
911
912#[derive(Debug, Deserialize, JsonSchema)]
913pub struct DetectImpactParams {
914    /// Comma-separated list of changed file paths (repo-relative).
915    pub changed_files: String,
916    /// Branch to query (defaults to "main").
917    pub branch: Option<String>,
918}
919
920#[derive(Debug, Deserialize, JsonSchema)]
921pub struct GenerateMapParams {
922    /// Branch to document (defaults to "main").
923    pub branch: Option<String>,
924}
925
926// ── Prompt implementations ────────────────────────────────────────────────────
927
928#[prompt_router]
929impl GitCortexServer {
930    /// Analyse the blast radius of changed files before committing.
931    /// Walks the call graph from changed symbols to find all downstream callers
932    /// and produces a risk assessment (LOW / MEDIUM / HIGH / CRITICAL).
933    #[prompt(
934        name = "detect_impact",
935        description = "Pre-commit impact analysis — maps changed files to affected callers and scores risk"
936    )]
937    fn detect_impact(&self, Parameters(p): Parameters<DetectImpactParams>) -> GetPromptResult {
938        let branch = p.branch.as_deref().unwrap_or("main");
939        let files = p.changed_files.trim().to_owned();
940
941        let user_msg = format!(
942            r#"I am about to commit changes to these files on branch `{branch}`:
943
944{files}
945
946Please analyse the blast radius of these changes using the GitCortex knowledge graph:
947
9481. For each changed file call `list_definitions` to identify which symbols were likely touched.
9492. For each key function or struct, call `find_callers` to find direct callers.
9503. Repeat `find_callers` one level deeper for any HIGH-traffic callers.
9514. Summarise your findings as:
952   - **Changed symbols**: list each modified function/struct with its file and line.
953   - **Direct callers**: who calls the changed code.
954   - **Transitive callers**: notable callers two hops away.
955   - **Risk level**: LOW / MEDIUM / HIGH / CRITICAL with a one-line justification.
956   - **Recommended actions**: tests to run, reviewers to notify, docs to update.
957"#
958        );
959
960        GetPromptResult::new(vec![PromptMessage::new_text(
961            PromptMessageRole::User,
962            user_msg,
963        )])
964        .with_description("Impact analysis of staged changes using the call graph")
965    }
966
967    /// Generate a Mermaid architecture diagram from the knowledge graph.
968    /// Summarises modules, key structs/traits, and their relationships.
969    #[prompt(
970        name = "generate_map",
971        description = "Architecture documentation — produces a Mermaid diagram of modules, types, and key relationships"
972    )]
973    fn generate_map(&self, Parameters(p): Parameters<GenerateMapParams>) -> GetPromptResult {
974        let branch = p.branch.as_deref().unwrap_or("main");
975
976        let user_msg = format!(
977            r#"Generate an architecture map of this codebase on branch `{branch}` using GitCortex.
978
979Steps:
9801. Call `list_definitions` on each major source file to collect modules, structs, traits, and functions.
9812. Call `find_callers` on the top-level entry points to understand key execution flows.
9823. Call `lookup_symbol` on core traits to find all their implementors.
983
984Then produce:
985
986## Architecture Overview
987A prose summary (3–5 sentences) of what this codebase does and how it is structured.
988
989## Module Map
990```mermaid
991graph TD
992  %% Add nodes for each module/crate and edges for depends-on relationships
993```
994
995## Key Types
996A table: | Type | Kind | Responsibility | Implemented by |
997
998## Core Flows
999Numbered list of the 2–4 most important execution paths (entry point → key functions → output).
1000
1001## Dependency Notes
1002Any circular dependencies, large fan-outs, or architectural concerns visible in the graph.
1003"#
1004        );
1005
1006        GetPromptResult::new(vec![PromptMessage::new_text(
1007            PromptMessageRole::User,
1008            user_msg,
1009        )])
1010        .with_description(
1011            "Architecture documentation with Mermaid diagram from the knowledge graph",
1012        )
1013    }
1014}
1015
1016// ── Combined ServerHandler (tools + prompts) ──────────────────────────────────
1017
1018#[tool_handler]
1019#[prompt_handler(router = Self::prompt_router())]
1020impl rmcp::ServerHandler for GitCortexServer {}
1021
1022// ── Git diff helpers ──────────────────────────────────────────────────────────
1023
1024fn run_git_diff(repo_root: &Path, args: &[&str]) -> Option<String> {
1025    let out = std::process::Command::new("git")
1026        .args(args)
1027        .current_dir(repo_root)
1028        .output()
1029        .ok()?;
1030    if out.status.success() {
1031        String::from_utf8(out.stdout).ok()
1032    } else {
1033        None
1034    }
1035}
1036
1037/// Parse unified diff text into `(repo_relative_file_path, [(start_line, end_line)])`.
1038fn parse_diff_hunks(diff: &str) -> Vec<(String, Vec<(u32, u32)>)> {
1039    let mut result: Vec<(String, Vec<(u32, u32)>)> = Vec::new();
1040    let mut cur_file: Option<String> = None;
1041    let mut cur_hunks: Vec<(u32, u32)> = Vec::new();
1042
1043    for line in diff.lines() {
1044        if let Some(path) = line.strip_prefix("+++ b/") {
1045            if let Some(f) = cur_file.take() {
1046                if !cur_hunks.is_empty() {
1047                    result.push((f, std::mem::take(&mut cur_hunks)));
1048                }
1049            }
1050            cur_file = Some(path.to_owned());
1051        } else if line.starts_with("@@ ") {
1052            if let Some(hunk) = parse_hunk_header(line) {
1053                cur_hunks.push(hunk);
1054            }
1055        }
1056    }
1057    if let Some(f) = cur_file {
1058        if !cur_hunks.is_empty() {
1059            result.push((f, cur_hunks));
1060        }
1061    }
1062    result
1063}
1064
1065/// Extract the new-file line range from a unified diff hunk header.
1066/// `@@ -old_start[,old_count] +new_start[,new_count] @@`
1067fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
1068    let rest = line.strip_prefix("@@ ")?;
1069    let plus_pos = rest.find(" +")?;
1070    let new_part = &rest[plus_pos + 2..];
1071    let end = new_part.find(' ').unwrap_or(new_part.len());
1072    let range = &new_part[..end];
1073    if let Some(comma) = range.find(',') {
1074        let start: u32 = range[..comma].parse().ok()?;
1075        let count: u32 = range[comma + 1..].parse().ok()?;
1076        Some((start, start + count.saturating_sub(1)))
1077    } else {
1078        let start: u32 = range.parse().ok()?;
1079        Some((start, start))
1080    }
1081}