Skip to main content

gitcortex_mcp/mcp/
tools.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex};
3
4use gitcortex_core::{
5    schema::{NodeKind, Visibility},
6    store::{AttributeFilter, GraphStore},
7};
8use gitcortex_store::kuzu::KuzuGraphStore;
9
10use crate::embeddings::{Embedder, SemanticIndex};
11
12pub enum SemanticState {
13    /// Background initialiser not done yet — search is text-only.
14    Pending,
15    /// Model loaded and index populated.
16    Ready {
17        embedder: Box<Embedder>,
18        index: Box<SemanticIndex>,
19    },
20    /// Initialisation failed (no network, disk error, etc.) — text-only forever.
21    Disabled,
22}
23use rmcp::{
24    handler::server::router::tool::ToolRouter,
25    handler::server::wrapper::Parameters,
26    model::{
27        CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
28        PaginatedRequestParams, PromptMessage, PromptMessageRole,
29    },
30    prompt, prompt_handler, prompt_router,
31    service::RequestContext,
32    tool, tool_handler, tool_router, RoleServer,
33};
34use schemars::JsonSchema;
35use serde::Deserialize;
36use serde_json::json;
37
38// ── Parameter types ───────────────────────────────────────────────────────────
39
40#[derive(Debug, Deserialize, JsonSchema)]
41pub struct GcxDispatchParams {
42    /// Which graph operation to run. One of: lookup_symbol, find_callers, find_callees,
43    /// find_unused_symbols, get_subgraph, search_code, start_tour, wiki_symbol,
44    /// trace_path, list_definitions, symbol_context, list_symbols_in_range, graph_stats,
45    /// ast_search, type_hierarchy, find_importers, find_type_usages, module_dependencies,
46    /// get_call_sites.
47    pub action: String,
48    /// Parameters for the chosen action as a JSON object (same fields as the
49    /// individual tool: name, function_name, seed_name, query, file, branch,
50    /// depth, limit, direction, src, dst, start_line, end_line).
51    pub params: serde_json::Value,
52}
53
54#[derive(Debug, Deserialize, JsonSchema)]
55pub struct LookupSymbolParams {
56    /// Symbol name to search for (unqualified).
57    pub name: String,
58    /// When true, matches any symbol whose name *contains* `name` (substring).
59    /// When false (default), exact match only.
60    pub fuzzy: Option<bool>,
61    /// Branch name (defaults to "main" if omitted).
62    pub branch: Option<String>,
63}
64
65#[derive(Debug, Deserialize, JsonSchema)]
66pub struct FindCallersParams {
67    /// Name of the function/method to find callers of.
68    pub function_name: String,
69    /// How many hops to walk up the call graph (1–5, default 1).
70    /// depth=1 returns direct callers only. depth=3 walks three levels.
71    pub depth: Option<u8>,
72    pub branch: Option<String>,
73}
74
75#[derive(Debug, Deserialize, JsonSchema)]
76pub struct SymbolContextParams {
77    /// Symbol name to look up (unqualified).
78    pub name: String,
79    /// Branch name (defaults to current branch if omitted).
80    pub branch: Option<String>,
81}
82
83#[derive(Debug, Deserialize, JsonSchema)]
84pub struct ListDefinitionsParams {
85    /// Repo-relative path to a source file.
86    pub file: String,
87    pub branch: Option<String>,
88}
89
90#[derive(Debug, Deserialize, JsonSchema)]
91pub struct BranchDiffParams {
92    pub from_branch: String,
93    pub to_branch: String,
94}
95
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct DetectChangesParams {
98    /// Branch to query (defaults to "main" if omitted).
99    pub branch: Option<String>,
100}
101
102#[derive(Debug, Deserialize, JsonSchema)]
103pub struct FindCalleesParams {
104    /// Name of the function/method to trace callees of.
105    pub function_name: String,
106    /// How many hops to walk forward in the call graph (1–5, default 1).
107    pub depth: Option<u8>,
108    pub branch: Option<String>,
109}
110
111#[derive(Debug, Deserialize, JsonSchema)]
112pub struct FindImplementorsParams {
113    /// Trait, interface, or abstract class name to find implementors of.
114    pub trait_name: String,
115    pub branch: Option<String>,
116}
117
118#[derive(Debug, Deserialize, JsonSchema)]
119pub struct TypeHierarchyParams {
120    /// Type name (struct/class/trait/interface) to map relationships for.
121    pub name: String,
122    pub branch: Option<String>,
123}
124
125#[derive(Debug, Deserialize, JsonSchema)]
126pub struct FindImportersParams {
127    /// Symbol name to find importers of (the imported thing, unqualified).
128    pub name: String,
129    pub branch: Option<String>,
130}
131
132#[derive(Debug, Deserialize, JsonSchema)]
133pub struct GetCallSitesParams {
134    /// Function/method name to find call sites of.
135    pub name: String,
136    pub branch: Option<String>,
137}
138
139#[derive(Debug, Deserialize, JsonSchema)]
140pub struct FindTypeUsagesParams {
141    /// Type name (struct/class/trait/interface/enum) to find usages of.
142    pub name: String,
143    pub branch: Option<String>,
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct ModuleDependenciesParams {
148    /// Module name (file stem, e.g. "tools" for tools.rs) to list dependencies of.
149    pub name: String,
150    pub branch: Option<String>,
151}
152
153#[derive(Debug, Deserialize, JsonSchema)]
154pub struct TracePathParams {
155    /// Starting function/method name.
156    pub from: String,
157    /// Target function/method name.
158    pub to: String,
159    pub branch: Option<String>,
160}
161
162#[derive(Debug, Deserialize, JsonSchema)]
163pub struct ListSymbolsInRangeParams {
164    /// Repo-relative path to a source file.
165    pub file: String,
166    /// Start line of the range (1-indexed, inclusive).
167    pub start_line: u32,
168    /// End line of the range (1-indexed, inclusive).
169    pub end_line: u32,
170    pub branch: Option<String>,
171}
172
173#[derive(Debug, Deserialize, JsonSchema)]
174pub struct FindUnusedSymbolsParams {
175    /// Optional NodeKind filter: "function", "method", "struct", etc.
176    pub kind: Option<String>,
177    /// Max symbols returned (default 30, capped at 200). `count` always reports
178    /// the true total; `truncated` flags when the list was longer.
179    pub limit: Option<usize>,
180    pub branch: Option<String>,
181}
182
183#[derive(Debug, Deserialize, JsonSchema)]
184pub struct GetSubgraphParams {
185    /// Seed symbol name (unqualified).
186    pub seed_name: String,
187    /// How many hops to expand from the seed (1–5, default 1). Depth 2+ on a
188    /// high-degree hub returns a large subgraph — raise deliberately.
189    pub depth: Option<u8>,
190    /// Direction: "in" (callers/ancestors), "out" (callees/descendants), "both" (default).
191    pub direction: Option<String>,
192    /// Max nodes returned (default 30, capped at 200). Edges are filtered to the
193    /// kept node set; `truncated` flags when the neighbourhood was larger.
194    pub limit: Option<usize>,
195    pub branch: Option<String>,
196}
197
198#[derive(Debug, Deserialize, JsonSchema)]
199pub struct WikiSymbolParams {
200    /// Symbol to summarise (unqualified name).
201    pub name: String,
202    pub branch: Option<String>,
203}
204
205#[derive(Debug, Deserialize, JsonSchema)]
206pub struct SearchCodeParams {
207    /// Free-text query — substring matched against `name` and `qualified_name`.
208    pub query: String,
209    /// Max results (default 10, capped at 200).
210    pub limit: Option<usize>,
211    pub branch: Option<String>,
212}
213
214#[derive(Debug, Deserialize, JsonSchema)]
215pub struct StartTourParams {
216    /// Optional seed symbol — when given, the tour walks outward from it
217    /// along the call graph. When omitted, picks the highest-centrality
218    /// entry points across the repo.
219    pub seed: Option<String>,
220    /// How many steps in the tour (default 12, capped at 50).
221    pub limit: Option<usize>,
222    pub branch: Option<String>,
223}
224
225#[derive(Debug, Deserialize, JsonSchema)]
226pub struct GraphStatsParams {
227    /// Branch to summarise (defaults to current branch if omitted).
228    pub branch: Option<String>,
229}
230
231#[derive(Debug, Deserialize, JsonSchema)]
232pub struct AstSearchParams {
233    /// NodeKind filter: "function", "method", "struct", "trait", "interface",
234    /// "enum", "constant", "type_alias", "module", etc. Omit for any kind.
235    pub kind: Option<String>,
236    /// When set, match only async (true) or only non-async (false) symbols.
237    pub is_async: Option<bool>,
238    /// Visibility filter: "pub", "pub_crate", or "private".
239    pub visibility: Option<String>,
240    /// Inclusive lower bound on cyclomatic complexity. Symbols without a
241    /// recorded complexity are excluded when this is set.
242    pub min_complexity: Option<u32>,
243    /// Inclusive upper bound on cyclomatic complexity.
244    pub max_complexity: Option<u32>,
245    /// Case-insensitive substring the symbol name must contain.
246    pub name_contains: Option<String>,
247    /// Annotation/decorator name the symbol must carry (case-insensitive
248    /// substring): "Test" finds `@Test`, "route" finds `@app.route`,
249    /// "derive" finds `#[derive(...)]`.
250    pub annotation: Option<String>,
251    /// Max results (default 30, capped at 200).
252    pub limit: Option<usize>,
253    pub branch: Option<String>,
254}
255
256// ── Server ────────────────────────────────────────────────────────────────────
257
258/// The MCP server handler. One shared `KuzuGraphStore` wrapped in `Arc<Mutex>`
259/// so all handler calls can share state safely.
260#[derive(Clone)]
261pub struct GitCortexServer {
262    store: Arc<Mutex<KuzuGraphStore>>,
263    repo_root: PathBuf,
264    default_branch: String,
265    compact: bool,
266    /// Approximate token budget for a single tool's list payload. List-returning
267    /// tools truncate their items to fit this, setting `truncated: true`, so a
268    /// high-fan-out symbol can never make the graph arm dump more than a grep
269    /// would read. Configurable via `GCX_RESPONSE_BUDGET` (token count).
270    response_budget: usize,
271    /// Semantic search state. Starts as `Pending`; background task flips to
272    /// `Ready` once the model is loaded and missing vectors are embedded.
273    /// `Arc<Mutex<…>>` so the background task and all clone'd handler instances
274    /// share the same index.
275    pub semantic: Arc<Mutex<SemanticState>>,
276}
277
278/// Default per-tool response token budget when `GCX_RESPONSE_BUDGET` is unset.
279const DEFAULT_RESPONSE_BUDGET: usize = 2000;
280/// Floor so a misconfigured tiny budget still returns something useful.
281const MIN_RESPONSE_BUDGET: usize = 400;
282
283impl GitCortexServer {
284    pub fn new(repo_root: &Path) -> anyhow::Result<Self> {
285        Self::new_with_mode(repo_root, false)
286    }
287
288    pub fn new_with_mode(repo_root: &Path, compact: bool) -> anyhow::Result<Self> {
289        let store = KuzuGraphStore::open(repo_root)?;
290        let default_branch = detect_current_branch(repo_root).unwrap_or_else(|| "main".into());
291        let response_budget = std::env::var("GCX_RESPONSE_BUDGET")
292            .ok()
293            .and_then(|s| s.parse::<usize>().ok())
294            .unwrap_or(DEFAULT_RESPONSE_BUDGET)
295            .max(MIN_RESPONSE_BUDGET);
296        Ok(Self {
297            store: Arc::new(Mutex::new(store)),
298            repo_root: repo_root.to_owned(),
299            default_branch,
300            compact,
301            response_budget,
302            semantic: Arc::new(Mutex::new(SemanticState::Pending)),
303        })
304    }
305
306    /// Truncate a list of JSON items to fit `response_budget`, returning the
307    /// kept items and whether truncation occurred. Token size is estimated as
308    /// serialized bytes / 4 (the usual rule of thumb) — cheap and good enough
309    /// to bound payloads. Always keeps at least one item so a single large
310    /// result is never dropped to nothing.
311    fn budget_items(&self, items: Vec<serde_json::Value>) -> (Vec<serde_json::Value>, bool) {
312        let budget_bytes = self.response_budget * 4;
313        let mut kept: Vec<serde_json::Value> = Vec::with_capacity(items.len());
314        let mut used = 0usize;
315        let total = items.len();
316        for item in items {
317            let sz = item.to_string().len() + 2; // +2 for ", " separators
318            if !kept.is_empty() && used + sz > budget_bytes {
319                break;
320            }
321            used += sz;
322            kept.push(item);
323        }
324        let truncated = kept.len() < total;
325        (kept, truncated)
326    }
327
328    /// Return the shared arcs + branch needed by the background semantic indexer.
329    pub fn semantic_context(
330        &self,
331    ) -> (
332        Arc<Mutex<SemanticState>>,
333        Arc<Mutex<KuzuGraphStore>>,
334        String,
335    ) {
336        (
337            self.semantic.clone(),
338            self.store.clone(),
339            self.default_branch.clone(),
340        )
341    }
342
343    fn active_tool_router(&self) -> ToolRouter<Self> {
344        let mut router = Self::tool_router();
345        if self.compact {
346            for name in [
347                "lookup_symbol",
348                "find_callers",
349                "symbol_context",
350                "list_definitions",
351                "branch_diff_graph",
352                "detect_changes",
353                "find_callees",
354                "find_implementors",
355                "trace_path",
356                "list_symbols_in_range",
357                "find_unused_symbols",
358                "get_subgraph",
359                "wiki_symbol",
360                "search_code",
361                "start_tour",
362            ] {
363                router.disable_route(name);
364            }
365        }
366        router
367    }
368}
369
370/// First line of a node's captured signature, trimmed and length-capped, for
371/// embedding in tool results so the model can judge a symbol without opening
372/// its file. Empty string when no signature was captured.
373fn sig_line(n: &gitcortex_core::graph::Node) -> String {
374    const MAX: usize = 120;
375    let first = n
376        .metadata
377        .definition
378        .signature
379        .lines()
380        .next()
381        .unwrap_or("")
382        .trim();
383    if first.chars().count() > MAX {
384        let truncated: String = first.chars().take(MAX).collect();
385        format!("{truncated}…")
386    } else {
387        first.to_owned()
388    }
389}
390
391/// Parse a NodeKind from its snake_case string form (matches `NodeKind::Display`).
392fn parse_node_kind(s: &str) -> Option<NodeKind> {
393    Some(match s {
394        "folder" => NodeKind::Folder,
395        "file" => NodeKind::File,
396        "module" => NodeKind::Module,
397        "struct" => NodeKind::Struct,
398        "enum" => NodeKind::Enum,
399        "trait" => NodeKind::Trait,
400        "interface" => NodeKind::Interface,
401        "type_alias" => NodeKind::TypeAlias,
402        "function" => NodeKind::Function,
403        "method" => NodeKind::Method,
404        "property" => NodeKind::Property,
405        "constant" => NodeKind::Constant,
406        "macro" => NodeKind::Macro,
407        "annotation" => NodeKind::Annotation,
408        "enum_member" => NodeKind::EnumMember,
409        _ => return None,
410    })
411}
412
413/// Parse a Visibility from its snake_case string form.
414fn parse_visibility(s: &str) -> Option<Visibility> {
415    Some(match s {
416        "pub" => Visibility::Pub,
417        "pub_crate" => Visibility::PubCrate,
418        "private" => Visibility::Private,
419        _ => return None,
420    })
421}
422
423fn detect_current_branch(repo_root: &Path) -> Option<String> {
424    let out = std::process::Command::new("git")
425        .args(["symbolic-ref", "--short", "HEAD"])
426        .current_dir(repo_root)
427        .output()
428        .ok()?;
429    if out.status.success() {
430        let s = String::from_utf8(out.stdout).ok()?;
431        let b = s.trim().to_owned();
432        if b.is_empty() {
433            None
434        } else {
435            Some(b)
436        }
437    } else {
438        None
439    }
440}
441
442// ── Tool implementations ──────────────────────────────────────────────────────
443
444#[tool_router]
445impl GitCortexServer {
446    /// Look up all nodes (functions, structs, traits, etc.) by name.
447    #[tool(
448        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."
449    )]
450    fn lookup_symbol(&self, Parameters(p): Parameters<LookupSymbolParams>) -> CallToolResult {
451        let branch = p
452            .branch
453            .as_deref()
454            .unwrap_or(&self.default_branch)
455            .to_owned();
456        let fuzzy = p.fuzzy.unwrap_or(false);
457        let store = match self.store.lock() {
458            Ok(g) => g,
459            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
460        };
461        match store.lookup_symbol(&branch, &p.name, fuzzy) {
462            Ok(nodes) => {
463                let items: Vec<_> = nodes
464                    .iter()
465                    .map(|n| {
466                        json!({
467                            "id": n.id.as_str(),
468                            "kind": n.kind.to_string(),
469                            "name": n.name,
470                            "qualified_name": n.qualified_name,
471                            "file": n.file.display().to_string(),
472                            "start_line": n.span.start_line,
473                            "end_line": n.span.end_line,
474                            "visibility": format!("{:?}", n.metadata.visibility),
475                            "is_async": n.metadata.is_async,
476                            "is_unsafe": n.metadata.is_unsafe,
477                        })
478                    })
479                    .collect();
480                let (items, _) = self.budget_items(items);
481                CallToolResult::structured(json!(items))
482            }
483            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
484        }
485    }
486
487    /// Find all callers of a function or method, with optional multi-hop depth.
488    #[tool(
489        description = "Find callers of a function. depth=1 (default) = direct callers; \
490        depth=2..5 = multi-hop. Results capped per hop; total count always returned."
491    )]
492    fn find_callers(&self, Parameters(p): Parameters<FindCallersParams>) -> CallToolResult {
493        let branch = p
494            .branch
495            .as_deref()
496            .unwrap_or(&self.default_branch)
497            .to_owned();
498        let depth = p.depth.unwrap_or(1).max(1);
499        let store = match self.store.lock() {
500            Ok(g) => g,
501            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
502        };
503
504        // Cap the caller list. The risk level is computed from the true total,
505        // so a hub symbol still reports CRITICAL even though we return a head.
506        const MAX_CALLERS: usize = 25;
507        const MAX_PER_HOP: usize = 15;
508        if depth == 1 {
509            match store.find_callers(&branch, &p.function_name) {
510                Ok(nodes) => {
511                    let total = nodes.len();
512                    let items: Vec<_> = nodes
513                        .iter()
514                        .take(MAX_CALLERS)
515                        .map(|n| {
516                            json!({
517                                "hop": 1,
518                                "kind": n.kind.to_string(),
519                                "name": n.name,
520                                "qualified_name": n.qualified_name,
521                                "file": n.file.display().to_string(),
522                                "start_line": n.span.start_line,
523                                // Signature lets the model judge impact without
524                                // opening the caller's file — the biggest token
525                                // sink on refactor-impact questions.
526                                "signature": sig_line(n),
527                            })
528                        })
529                        .collect();
530                    let (items, budget_trunc) = self.budget_items(items);
531                    let risk = match total {
532                        0..=2 => "LOW",
533                        3..=10 => "MEDIUM",
534                        11..=30 => "HIGH",
535                        _ => "CRITICAL",
536                    };
537                    CallToolResult::structured(json!({
538                        "summary": format!("{total} caller(s) — risk {risk}{}",
539                            if total > items.len() {
540                                format!(", showing top {}", items.len())
541                            } else { String::new() }),
542                        "function": p.function_name,
543                        "depth": 1,
544                        "risk_level": risk,
545                        "total_callers": total,
546                        "returned": items.len(),
547                        "truncated": total > items.len() || budget_trunc,
548                        "callers": items,
549                    }))
550                }
551                Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
552            }
553        } else {
554            match store.find_callers_deep(&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 total = nodes.len();
562                            let callers: Vec<_> = nodes
563                                .iter()
564                                .take(MAX_PER_HOP)
565                                .map(|n| {
566                                    json!({
567                                        "kind": n.kind.to_string(),
568                                        "name": n.name,
569                                        "qualified_name": n.qualified_name,
570                                        "file": n.file.display().to_string(),
571                                        "start_line": n.span.start_line,
572                                        "signature": sig_line(n),
573                                    })
574                                })
575                                .collect();
576                            json!({
577                                "hop": i + 1,
578                                "total": total,
579                                "truncated": total > MAX_PER_HOP,
580                                "callers": callers,
581                            })
582                        })
583                        .collect();
584                    CallToolResult::structured(json!({
585                        "function": p.function_name,
586                        "depth": depth,
587                        "risk_level": result.risk_level,
588                        "hops": hops,
589                    }))
590                }
591                Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
592            }
593        }
594    }
595
596    /// Get a 360° view of a symbol: definition, callers, callees, and type usages.
597    #[tool(
598        description = "Get a complete picture of a symbol in one call: where it's defined, \
599        what calls it (callers), what it calls (callees), and which code references it as a type. \
600        Use this instead of chaining lookup_symbol + find_callers separately."
601    )]
602    fn symbol_context(&self, Parameters(p): Parameters<SymbolContextParams>) -> CallToolResult {
603        let branch = p
604            .branch
605            .as_deref()
606            .unwrap_or(&self.default_branch)
607            .to_owned();
608        let store = match self.store.lock() {
609            Ok(g) => g,
610            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
611        };
612        match store.symbol_context(&branch, &p.name) {
613            Ok(ctx) => {
614                let node_json = |n: &gitcortex_core::graph::Node| {
615                    json!({
616                        "kind": n.kind.to_string(),
617                        "name": n.name,
618                        "qualified_name": n.qualified_name,
619                        "file": n.file.display().to_string(),
620                        "start_line": n.span.start_line,
621                    })
622                };
623                CallToolResult::structured(json!({
624                    "definition": {
625                        "kind": ctx.definition.kind.to_string(),
626                        "name": ctx.definition.name,
627                        "qualified_name": ctx.definition.qualified_name,
628                        "file": ctx.definition.file.display().to_string(),
629                        "start_line": ctx.definition.span.start_line,
630                        "end_line": ctx.definition.span.end_line,
631                        "visibility": format!("{:?}", ctx.definition.metadata.visibility),
632                        "is_async": ctx.definition.metadata.is_async,
633                        "complexity": ctx.definition.metadata.lld.complexity,
634                    },
635                    "callers": ctx.callers.iter().map(node_json).collect::<Vec<_>>(),
636                    "callees": ctx.callees.iter().map(node_json).collect::<Vec<_>>(),
637                    "used_by": ctx.used_by.iter().map(node_json).collect::<Vec<_>>(),
638                }))
639            }
640            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
641        }
642    }
643
644    /// List all symbols defined in a source file, ordered by line number.
645    #[tool(
646        description = "List all functions, structs, traits, and other definitions in a source file, ordered by line number."
647    )]
648    fn list_definitions(&self, Parameters(p): Parameters<ListDefinitionsParams>) -> CallToolResult {
649        let branch = p
650            .branch
651            .as_deref()
652            .unwrap_or(&self.default_branch)
653            .to_owned();
654        let store = match self.store.lock() {
655            Ok(g) => g,
656            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
657        };
658        match store.list_definitions(&branch, Path::new(&p.file)) {
659            Ok(nodes) => {
660                let items: Vec<_> = nodes
661                    .iter()
662                    .map(|n| {
663                        json!({
664                            "kind": n.kind.to_string(),
665                            "name": n.name,
666                            "qualified_name": n.qualified_name,
667                            "start_line": n.span.start_line,
668                            "end_line": n.span.end_line,
669                            "loc": n.metadata.loc,
670                            "visibility": format!("{:?}", n.metadata.visibility),
671                            "is_async": n.metadata.is_async,
672                        })
673                    })
674                    .collect();
675                let (items, _) = self.budget_items(items);
676                CallToolResult::structured(json!(items))
677            }
678            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
679        }
680    }
681
682    /// Aggregate counts for the branch's graph — orientation before exploring.
683    #[tool(
684        description = "Get aggregate counts for the code graph: total nodes/edges plus per-kind breakdowns (how many functions, structs, calls edges, etc). Use this first to gauge codebase size and shape before drilling into specific symbols."
685    )]
686    fn graph_stats(&self, Parameters(p): Parameters<GraphStatsParams>) -> CallToolResult {
687        let branch = p
688            .branch
689            .as_deref()
690            .unwrap_or(&self.default_branch)
691            .to_owned();
692        let store = match self.store.lock() {
693            Ok(g) => g,
694            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
695        };
696        match store.graph_stats(&branch) {
697            Ok(stats) => {
698                let to_obj = |pairs: &[(String, u64)]| -> serde_json::Value {
699                    json!(pairs
700                        .iter()
701                        .map(|(k, c)| json!({ "kind": k, "count": c }))
702                        .collect::<Vec<_>>())
703                };
704                CallToolResult::structured(json!({
705                    "branch": branch,
706                    "total_nodes": stats.total_nodes,
707                    "total_edges": stats.total_edges,
708                    "nodes_by_kind": to_obj(&stats.nodes_by_kind),
709                    "edges_by_kind": to_obj(&stats.edges_by_kind),
710                }))
711            }
712            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
713        }
714    }
715
716    /// Structural search over node attributes (no name needed).
717    #[tool(
718        description = "Find symbols by structural attributes rather than name: kind (function/method/struct/...), is_async, visibility (pub/pub_crate/private), cyclomatic complexity range, and annotation/decorator (e.g. annotation='Test' finds @Test methods, 'route' finds @app.route handlers, 'derive' finds #[derive(...)]). Combine filters to answer 'all async methods', 'public structs', 'functions with complexity ≥ 10', or 'all test functions'. Optional name_contains narrows further. Default limit=30."
719    )]
720    fn ast_search(&self, Parameters(p): Parameters<AstSearchParams>) -> CallToolResult {
721        let branch = p
722            .branch
723            .as_deref()
724            .unwrap_or(&self.default_branch)
725            .to_owned();
726        let limit = p.limit.unwrap_or(30).min(200);
727
728        let kind = p.kind.as_deref().and_then(parse_node_kind);
729        // Reject an unknown kind string rather than silently ignoring it.
730        if p.kind.is_some() && kind.is_none() {
731            return CallToolResult::error(vec![Content::text(format!(
732                "unknown kind '{}'. Valid: function, method, struct, enum, trait, \
733                 interface, type_alias, property, constant, macro, annotation, \
734                 enum_member, module, file, folder",
735                p.kind.as_deref().unwrap_or("")
736            ))]);
737        }
738        let visibility = p.visibility.as_deref().and_then(parse_visibility);
739        if p.visibility.is_some() && visibility.is_none() {
740            return CallToolResult::error(vec![Content::text(
741                "unknown visibility. Valid: pub, pub_crate, private".to_owned(),
742            )]);
743        }
744
745        let filter = AttributeFilter {
746            kind,
747            is_async: p.is_async,
748            visibility,
749            min_complexity: p.min_complexity,
750            max_complexity: p.max_complexity,
751            name_contains: p.name_contains.clone(),
752            annotation: p.annotation.clone(),
753        };
754
755        if filter.is_empty() {
756            return CallToolResult::error(vec![Content::text(
757                "ast_search needs at least one filter (kind, is_async, visibility, \
758                 complexity bound, name_contains, or annotation)"
759                    .to_owned(),
760            )]);
761        }
762
763        let store = match self.store.lock() {
764            Ok(g) => g,
765            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
766        };
767        match store.search_by_attributes(&branch, &filter, limit) {
768            Ok(nodes) => {
769                let items: Vec<_> = nodes
770                    .iter()
771                    .map(|n| {
772                        json!({
773                            "kind": n.kind.to_string(),
774                            "name": n.name,
775                            "qualified_name": n.qualified_name,
776                            "file": n.file.display().to_string(),
777                            "start_line": n.span.start_line,
778                            "visibility": format!("{:?}", n.metadata.visibility),
779                            "is_async": n.metadata.is_async,
780                            "complexity": n.metadata.lld.complexity,
781                            "annotations": n.metadata.annotations,
782                        })
783                    })
784                    .collect();
785                let (items, truncated) = self.budget_items(items);
786                CallToolResult::structured(json!({
787                    "branch": branch,
788                    "results": items,
789                    "returned": items.len(),
790                    "truncated": truncated,
791                }))
792            }
793            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
794        }
795    }
796
797    /// Compute the graph diff between two branches.
798    #[tool(
799        description = "Show what nodes were added or removed between two branches. Useful for understanding what changed in a feature branch vs main."
800    )]
801    fn branch_diff_graph(&self, Parameters(p): Parameters<BranchDiffParams>) -> CallToolResult {
802        let store = match self.store.lock() {
803            Ok(g) => g,
804            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
805        };
806        match store.branch_diff(&p.from_branch, &p.to_branch) {
807            Ok(diff) => {
808                let added: Vec<_> = diff
809                    .added_nodes
810                    .iter()
811                    .map(|n| {
812                        json!({
813                            "kind": n.kind.to_string(),
814                            "name": n.name,
815                            "file": n.file.display().to_string(),
816                            "start_line": n.span.start_line,
817                        })
818                    })
819                    .collect();
820
821                // Resolve removed node IDs to full node objects from the from_branch.
822                let from_nodes = store.list_all_nodes(&p.from_branch).unwrap_or_default();
823                let from_map: std::collections::HashMap<_, _> =
824                    from_nodes.iter().map(|n| (n.id.clone(), n)).collect();
825                let removed: Vec<_> = diff
826                    .removed_node_ids
827                    .iter()
828                    .filter_map(|id| from_map.get(id))
829                    .map(|n| {
830                        json!({
831                            "kind": n.kind.to_string(),
832                            "name": n.name,
833                            "file": n.file.display().to_string(),
834                            "start_line": n.span.start_line,
835                        })
836                    })
837                    .collect();
838
839                CallToolResult::structured(json!({
840                    "from": p.from_branch,
841                    "to": p.to_branch,
842                    "added_nodes": added,
843                    "removed_nodes": removed,
844                }))
845            }
846            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
847        }
848    }
849
850    /// Detect which indexed symbols are affected by current staged (or HEAD) changes.
851    #[tool(
852        description = "Map the current git diff (staged changes, or HEAD diff if nothing is staged) \
853        to the indexed symbol graph. Returns which functions/structs were changed, their direct callers, \
854        and a risk level. Use this before committing to understand blast radius automatically."
855    )]
856    fn detect_changes(&self, Parameters(p): Parameters<DetectChangesParams>) -> CallToolResult {
857        let branch = p
858            .branch
859            .as_deref()
860            .unwrap_or(&self.default_branch)
861            .to_owned();
862
863        let diff_text = run_git_diff(&self.repo_root, &["diff", "--staged"])
864            .filter(|s| !s.trim().is_empty())
865            .or_else(|| run_git_diff(&self.repo_root, &["diff", "HEAD"]))
866            .unwrap_or_default();
867
868        if diff_text.trim().is_empty() {
869            return CallToolResult::success(vec![Content::text(
870                "No staged or unstaged changes detected.",
871            )]);
872        }
873
874        let hunks = parse_diff_hunks(&diff_text);
875        let store = match self.store.lock() {
876            Ok(g) => g,
877            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
878        };
879
880        let mut changed_symbols: Vec<serde_json::Value> = Vec::new();
881        let mut total_affected: usize = 0;
882
883        for (file_path, ranges) in &hunks {
884            let path = PathBuf::from(file_path);
885            let definitions = match store.list_definitions(&branch, &path) {
886                Ok(d) => d,
887                Err(_) => continue,
888            };
889            for node in &definitions {
890                let overlaps = ranges
891                    .iter()
892                    .any(|(s, e)| node.span.start_line <= *e && node.span.end_line >= *s);
893                if !overlaps {
894                    continue;
895                }
896                let callers = store.find_callers(&branch, &node.name).unwrap_or_default();
897                let caller_names: Vec<&str> = callers.iter().map(|c| c.name.as_str()).collect();
898                total_affected += 1 + caller_names.len();
899                changed_symbols.push(json!({
900                    "kind": node.kind.to_string(),
901                    "name": node.name,
902                    "file": file_path,
903                    "start_line": node.span.start_line,
904                    "end_line": node.span.end_line,
905                    "callers": caller_names,
906                }));
907            }
908        }
909
910        if changed_symbols.is_empty() {
911            return CallToolResult::success(vec![Content::text(
912                "Changed lines do not overlap with any indexed symbols.",
913            )]);
914        }
915
916        let risk_level = match total_affected {
917            0..=5 => "LOW",
918            6..=20 => "MEDIUM",
919            21..=50 => "HIGH",
920            _ => "CRITICAL",
921        };
922
923        CallToolResult::structured(json!({
924            "risk_level": risk_level,
925            "total_affected": total_affected,
926            "changed_symbols": changed_symbols,
927        }))
928    }
929
930    /// Find all callees of a function/method, tracing forward through the call graph.
931    #[tool(
932        description = "Find all functions/methods that the named function calls. \
933        Inverse of find_callers — traces forward (downstream). Use depth=1..5 to walk multiple hops. \
934        Returns callees grouped by hop distance."
935    )]
936    fn find_callees(&self, Parameters(p): Parameters<FindCalleesParams>) -> CallToolResult {
937        let branch = p
938            .branch
939            .as_deref()
940            .unwrap_or(&self.default_branch)
941            .to_owned();
942        let depth = p.depth.unwrap_or(1).max(1);
943        let store = match self.store.lock() {
944            Ok(g) => g,
945            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
946        };
947        match store.find_callees(&branch, &p.function_name, depth) {
948            Ok(result) => {
949                let hops: Vec<_> = result
950                    .hops
951                    .iter()
952                    .enumerate()
953                    .map(|(i, nodes)| {
954                        let callees: Vec<_> = nodes
955                            .iter()
956                            .map(|n| {
957                                json!({
958                                    "kind": n.kind.to_string(),
959                                    "name": n.name,
960                                    "qualified_name": n.qualified_name,
961                                    "file": n.file.display().to_string(),
962                                    "start_line": n.span.start_line,
963                                })
964                            })
965                            .collect();
966                        json!({ "hop": i + 1, "callees": callees })
967                    })
968                    .collect();
969                CallToolResult::structured(json!({
970                    "function": p.function_name,
971                    "depth": depth,
972                    "hops": hops,
973                }))
974            }
975            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
976        }
977    }
978
979    /// Find all structs/classes that implement a trait or interface.
980    #[tool(
981        description = "Find all concrete types (structs, classes) that implement or inherit the named \
982        trait or interface. Works for Rust traits, Java/TypeScript interfaces, and Go structural types."
983    )]
984    fn find_implementors(
985        &self,
986        Parameters(p): Parameters<FindImplementorsParams>,
987    ) -> CallToolResult {
988        let branch = p
989            .branch
990            .as_deref()
991            .unwrap_or(&self.default_branch)
992            .to_owned();
993        let store = match self.store.lock() {
994            Ok(g) => g,
995            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
996        };
997        match store.find_implementors(&branch, &p.trait_name) {
998            Ok(nodes) => {
999                let items: Vec<_> = nodes
1000                    .iter()
1001                    .map(|n| {
1002                        json!({
1003                            "kind": n.kind.to_string(),
1004                            "name": n.name,
1005                            "qualified_name": n.qualified_name,
1006                            "file": n.file.display().to_string(),
1007                            "start_line": n.span.start_line,
1008                        })
1009                    })
1010                    .collect();
1011                let (items, truncated) = self.budget_items(items);
1012                CallToolResult::structured(json!({
1013                    "trait": p.trait_name,
1014                    "implementors": items,
1015                    "truncated": truncated,
1016                }))
1017            }
1018            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1019        }
1020    }
1021
1022    /// List the in-repo modules a module depends on.
1023    #[tool(
1024        description = "List the in-repo modules a given module depends on, resolved by following its imports to the defining module of each imported symbol. Useful for understanding internal coupling and architecture. Only intra-repo dependencies appear (external/stdlib imports are not graphed)."
1025    )]
1026    fn module_dependencies(
1027        &self,
1028        Parameters(p): Parameters<ModuleDependenciesParams>,
1029    ) -> CallToolResult {
1030        let branch = p
1031            .branch
1032            .as_deref()
1033            .unwrap_or(&self.default_branch)
1034            .to_owned();
1035        let store = match self.store.lock() {
1036            Ok(g) => g,
1037            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1038        };
1039        match store.module_dependencies(&branch, &p.name) {
1040            Ok(nodes) => {
1041                let items: Vec<_> = nodes
1042                    .iter()
1043                    .map(|n| {
1044                        json!({
1045                            "name": n.name,
1046                            "file": n.file.display().to_string(),
1047                        })
1048                    })
1049                    .collect();
1050                CallToolResult::structured(json!({
1051                    "module": p.name,
1052                    "depends_on": items,
1053                }))
1054            }
1055            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1056        }
1057    }
1058
1059    /// Find functions/methods that use a type in their signature.
1060    #[tool(
1061        description = "Find functions/methods that reference a type as a parameter or return type (follows Uses edges). The type-level analogue of find_callers: answers 'what would break if I change type T's shape'. Returns the using functions/methods."
1062    )]
1063    fn find_type_usages(&self, Parameters(p): Parameters<FindTypeUsagesParams>) -> CallToolResult {
1064        let branch = p
1065            .branch
1066            .as_deref()
1067            .unwrap_or(&self.default_branch)
1068            .to_owned();
1069        let store = match self.store.lock() {
1070            Ok(g) => g,
1071            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1072        };
1073        match store.find_type_usages(&branch, &p.name) {
1074            Ok(nodes) => {
1075                let items: Vec<_> = nodes
1076                    .iter()
1077                    .map(|n| {
1078                        json!({
1079                            "kind": n.kind.to_string(),
1080                            "name": n.name,
1081                            "qualified_name": n.qualified_name,
1082                            "file": n.file.display().to_string(),
1083                            "start_line": n.span.start_line,
1084                        })
1085                    })
1086                    .collect();
1087                let (items, truncated) = self.budget_items(items);
1088                CallToolResult::structured(json!({
1089                    "type": p.name,
1090                    "usages": items,
1091                    "truncated": truncated,
1092                }))
1093            }
1094            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1095        }
1096    }
1097
1098    /// Find the exact call sites (caller + line) of a function.
1099    #[tool(
1100        description = "Find every call site of a function: the calling symbol AND the source line of each call. Where find_callers gives only the calling functions, this pinpoints the exact line each call happens on — useful for reviewing or editing every invocation."
1101    )]
1102    fn get_call_sites(&self, Parameters(p): Parameters<GetCallSitesParams>) -> CallToolResult {
1103        let branch = p
1104            .branch
1105            .as_deref()
1106            .unwrap_or(&self.default_branch)
1107            .to_owned();
1108        let store = match self.store.lock() {
1109            Ok(g) => g,
1110            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1111        };
1112        match store.find_call_sites(&branch, &p.name) {
1113            Ok(sites) => {
1114                let items: Vec<_> = sites
1115                    .iter()
1116                    .map(|s| {
1117                        json!({
1118                            "caller": s.caller.name,
1119                            "caller_kind": s.caller.kind.to_string(),
1120                            "file": s.caller.file.display().to_string(),
1121                            "line": s.line,
1122                            "caller_start_line": s.caller.span.start_line,
1123                        })
1124                    })
1125                    .collect();
1126                let total = items.len();
1127                let (items, truncated) = self.budget_items(items);
1128                CallToolResult::structured(json!({
1129                    "function": p.name,
1130                    "call_sites": items,
1131                    "count": total,
1132                    "returned": items.len(),
1133                    "truncated": truncated,
1134                }))
1135            }
1136            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1137        }
1138    }
1139
1140    /// Find which files/modules import a given symbol.
1141    #[tool(
1142        description = "Find which files/modules import a given symbol (follows Imports edges). Answers 'who depends on X' at the import level — useful before renaming or moving a symbol. Returns the importing module nodes."
1143    )]
1144    fn find_importers(&self, Parameters(p): Parameters<FindImportersParams>) -> CallToolResult {
1145        let branch = p
1146            .branch
1147            .as_deref()
1148            .unwrap_or(&self.default_branch)
1149            .to_owned();
1150        let store = match self.store.lock() {
1151            Ok(g) => g,
1152            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1153        };
1154        match store.find_importers(&branch, &p.name) {
1155            Ok(nodes) => {
1156                let items: Vec<_> = nodes
1157                    .iter()
1158                    .map(|n| {
1159                        json!({
1160                            "kind": n.kind.to_string(),
1161                            "name": n.name,
1162                            "qualified_name": n.qualified_name,
1163                            "file": n.file.display().to_string(),
1164                            "start_line": n.span.start_line,
1165                        })
1166                    })
1167                    .collect();
1168                let (items, truncated) = self.budget_items(items);
1169                CallToolResult::structured(json!({
1170                    "symbol": p.name,
1171                    "importers": items,
1172                    "truncated": truncated,
1173                }))
1174            }
1175            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1176        }
1177    }
1178
1179    /// Map both directions of a type's relationships in one call.
1180    #[tool(
1181        description = "Map a type's full relationship hierarchy in one call: supertypes (the traits/interfaces/classes it implements or extends) AND subtypes (the types that implement or extend it). Where find_implementors gives only the downward direction, this gives both. Works across Rust traits, Java/TypeScript interfaces, and inheritance chains."
1182    )]
1183    fn type_hierarchy(&self, Parameters(p): Parameters<TypeHierarchyParams>) -> CallToolResult {
1184        let branch = p
1185            .branch
1186            .as_deref()
1187            .unwrap_or(&self.default_branch)
1188            .to_owned();
1189        let store = match self.store.lock() {
1190            Ok(g) => g,
1191            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1192        };
1193        match store.type_hierarchy(&branch, &p.name) {
1194            Ok(h) => {
1195                let to_items = |nodes: &[gitcortex_core::graph::Node]| -> serde_json::Value {
1196                    json!(nodes
1197                        .iter()
1198                        .map(|n| json!({
1199                            "kind": n.kind.to_string(),
1200                            "name": n.name,
1201                            "qualified_name": n.qualified_name,
1202                            "file": n.file.display().to_string(),
1203                            "start_line": n.span.start_line,
1204                        }))
1205                        .collect::<Vec<_>>())
1206                };
1207                CallToolResult::structured(json!({
1208                    "type": p.name,
1209                    "supertypes": to_items(&h.supertypes),
1210                    "subtypes": to_items(&h.subtypes),
1211                }))
1212            }
1213            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1214        }
1215    }
1216
1217    /// Find a call path between two symbols in the codebase.
1218    #[tool(
1219        description = "Find a call path from one function to another. Returns the shortest chain of \
1220        calls connecting `from` to `to`. Returns an empty array if no path exists within 6 hops. \
1221        Most useful for debugging 'how can A reach B?' questions."
1222    )]
1223    fn trace_path(&self, Parameters(p): Parameters<TracePathParams>) -> CallToolResult {
1224        let branch = p
1225            .branch
1226            .as_deref()
1227            .unwrap_or(&self.default_branch)
1228            .to_owned();
1229        let store = match self.store.lock() {
1230            Ok(g) => g,
1231            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1232        };
1233        match store.trace_path(&branch, &p.from, &p.to) {
1234            Ok(path) => {
1235                let nodes: Vec<_> = path
1236                    .iter()
1237                    .map(|n| {
1238                        json!({
1239                            "kind": n.kind.to_string(),
1240                            "name": n.name,
1241                            "file": n.file.display().to_string(),
1242                            "start_line": n.span.start_line,
1243                        })
1244                    })
1245                    .collect();
1246                CallToolResult::structured(json!({
1247                    "from": p.from,
1248                    "to": p.to,
1249                    "found": !path.is_empty(),
1250                    "path": nodes,
1251                }))
1252            }
1253            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1254        }
1255    }
1256
1257    /// Find all indexed symbols that overlap a line range in a file.
1258    #[tool(
1259        description = "List all symbols (functions, structs, etc.) in a source file whose span \
1260        overlaps the given line range. Use this to map a stack trace, diff hunk, or grep result \
1261        to the symbols responsible."
1262    )]
1263    fn list_symbols_in_range(
1264        &self,
1265        Parameters(p): Parameters<ListSymbolsInRangeParams>,
1266    ) -> CallToolResult {
1267        let branch = p
1268            .branch
1269            .as_deref()
1270            .unwrap_or(&self.default_branch)
1271            .to_owned();
1272        let store = match self.store.lock() {
1273            Ok(g) => g,
1274            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1275        };
1276        let path = Path::new(&p.file);
1277        match store.list_symbols_in_range(&branch, path, p.start_line, p.end_line) {
1278            Ok(nodes) => {
1279                let items: Vec<_> = nodes
1280                    .iter()
1281                    .map(|n| {
1282                        json!({
1283                            "kind": n.kind.to_string(),
1284                            "name": n.name,
1285                            "qualified_name": n.qualified_name,
1286                            "start_line": n.span.start_line,
1287                            "end_line": n.span.end_line,
1288                            "loc": n.metadata.loc,
1289                        })
1290                    })
1291                    .collect();
1292                let (items, truncated) = self.budget_items(items);
1293                CallToolResult::structured(json!({
1294                    "file": p.file,
1295                    "range": { "start": p.start_line, "end": p.end_line },
1296                    "symbols": items,
1297                    "truncated": truncated,
1298                }))
1299            }
1300            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1301        }
1302    }
1303
1304    /// Find symbols with no callers or type references — potential dead code.
1305    #[tool(
1306        description = "Find symbols that are never called or used as a type anywhere in the indexed \
1307        codebase. Useful for identifying dead code, safe-to-rename candidates, or refactoring targets. \
1308        Pass kind='function' to restrict to functions only."
1309    )]
1310    fn find_unused_symbols(
1311        &self,
1312        Parameters(p): Parameters<FindUnusedSymbolsParams>,
1313    ) -> CallToolResult {
1314        let branch = p
1315            .branch
1316            .as_deref()
1317            .unwrap_or(&self.default_branch)
1318            .to_owned();
1319        let kind = p.kind.as_deref().and_then(|k| match k {
1320            "function" => Some(NodeKind::Function),
1321            "method" => Some(NodeKind::Method),
1322            "struct" => Some(NodeKind::Struct),
1323            "trait" => Some(NodeKind::Trait),
1324            "interface" => Some(NodeKind::Interface),
1325            "enum" => Some(NodeKind::Enum),
1326            "constant" => Some(NodeKind::Constant),
1327            _ => None,
1328        });
1329        let store = match self.store.lock() {
1330            Ok(g) => g,
1331            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1332        };
1333        let limit = p.limit.unwrap_or(30).min(200);
1334        match store.find_unused_symbols(&branch, kind) {
1335            Ok(nodes) => {
1336                // Return a ranked head, not the whole list. An agent acts on the
1337                // first handful; dumping every unused symbol costs more tokens
1338                // than a grep the model would have run instead.
1339                let items: Vec<_> = nodes
1340                    .iter()
1341                    .take(limit)
1342                    .map(|n| {
1343                        json!({
1344                            "kind": n.kind.to_string(),
1345                            "name": n.name,
1346                            "qualified_name": n.qualified_name,
1347                            "file": n.file.display().to_string(),
1348                            "start_line": n.span.start_line,
1349                            "visibility": format!("{:?}", n.metadata.visibility),
1350                        })
1351                    })
1352                    .collect();
1353                let total = nodes.len();
1354                let (items, budget_trunc) = self.budget_items(items);
1355                CallToolResult::structured(json!({
1356                    "branch": branch,
1357                    "unused_symbols": items,
1358                    "count": total,
1359                    "returned": items.len(),
1360                    "truncated": total > items.len() || budget_trunc,
1361                }))
1362            }
1363            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1364        }
1365    }
1366
1367    /// Return a neighbourhood subgraph around a seed symbol.
1368    #[tool(
1369        description = "Return the subgraph centred on a seed symbol — nodes and edges reachable \
1370        within `depth` hops (default 1; raise for wider context). Direction='out' downstream, \
1371        'in' upstream, 'both' (default). Capped at `limit` nodes (default 30) with a `truncated` \
1372        flag — prefer find_callers/find_callees for a targeted answer over a wide neighbourhood dump."
1373    )]
1374    fn get_subgraph(&self, Parameters(p): Parameters<GetSubgraphParams>) -> CallToolResult {
1375        let branch = p
1376            .branch
1377            .as_deref()
1378            .unwrap_or(&self.default_branch)
1379            .to_owned();
1380        let depth = p.depth.unwrap_or(1).clamp(1, 5);
1381        let max_nodes = p.limit.unwrap_or(20).min(200);
1382        let direction = p.direction.as_deref().unwrap_or("both").to_owned();
1383        let store = match self.store.lock() {
1384            Ok(g) => g,
1385            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1386        };
1387        match store.get_subgraph(&branch, &p.seed_name, depth, &direction) {
1388            Ok(sg) => {
1389                // Cap the node set, then keep only edges whose endpoints both
1390                // survive — a full neighbourhood dump on a hub symbol otherwise
1391                // costs more tokens than reading the file it describes.
1392                let kept: Vec<_> = sg.nodes.iter().take(max_nodes).collect();
1393                let kept_ids: std::collections::HashSet<String> =
1394                    kept.iter().map(|n| n.id.as_str()).collect();
1395                // id → name, so edges read as names (the model can't use UUIDs;
1396                // emitting them twice per edge is pure token waste).
1397                let name_of: std::collections::HashMap<String, &str> = kept
1398                    .iter()
1399                    .map(|n| (n.id.as_str(), n.name.as_str()))
1400                    .collect();
1401                let nodes: Vec<_> = kept
1402                    .iter()
1403                    .map(|n| {
1404                        json!({
1405                            "kind": n.kind.to_string(),
1406                            "name": n.name,
1407                            "file": n.file.display().to_string(),
1408                            "start_line": n.span.start_line,
1409                        })
1410                    })
1411                    .collect();
1412                let edges: Vec<_> = sg
1413                    .edges
1414                    .iter()
1415                    .filter(|e| {
1416                        kept_ids.contains(&e.src.as_str()) && kept_ids.contains(&e.dst.as_str())
1417                    })
1418                    .map(|e| {
1419                        json!({
1420                            "from": name_of.get(&e.src.as_str()).copied().unwrap_or(""),
1421                            "to": name_of.get(&e.dst.as_str()).copied().unwrap_or(""),
1422                            "kind": e.kind.to_string(),
1423                            "confidence": e.confidence.to_string(),
1424                        })
1425                    })
1426                    .collect();
1427                // Enforce the response token budget across both lists.
1428                let (nodes, n_trunc) = self.budget_items(nodes);
1429                let (edges, e_trunc) = self.budget_items(edges);
1430                CallToolResult::structured(json!({
1431                    "seed": p.seed_name,
1432                    "depth": depth,
1433                    "direction": direction,
1434                    "node_count": sg.nodes.len(),
1435                    "edge_count": sg.edges.len(),
1436                    "returned_nodes": nodes.len(),
1437                    "returned_edges": edges.len(),
1438                    "truncated": sg.nodes.len() > nodes.len() || n_trunc || e_trunc,
1439                    "nodes": nodes,
1440                    "edges": edges,
1441                }))
1442            }
1443            Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1444        }
1445    }
1446
1447    /// Render a wiki-style markdown summary for a symbol.
1448    #[tool(
1449        description = "Markdown wiki for a symbol: signature, doc-comment, top callers/callees. \
1450        Use for deep explanation; use lookup_symbol for a quick definition."
1451    )]
1452    fn wiki_symbol(&self, Parameters(p): Parameters<WikiSymbolParams>) -> CallToolResult {
1453        let branch = p
1454            .branch
1455            .as_deref()
1456            .unwrap_or(&self.default_branch)
1457            .to_owned();
1458        let store = match self.store.lock() {
1459            Ok(g) => g,
1460            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1461        };
1462        match super::wiki::render_symbol(&*store, &branch, &p.name) {
1463            Ok(markdown) => CallToolResult::structured(json!({
1464                "symbol": p.name,
1465                "branch": branch,
1466                "markdown": markdown,
1467            })),
1468            Err(e) => CallToolResult::error(vec![Content::text(format!("wiki failed: {e}"))]),
1469        }
1470    }
1471
1472    /// Search the graph by name + qualified-name with deterministic ranking.
1473    #[tool(
1474        description = "Search the code graph by name or description. Combines token/fuzzy text \
1475        matching (CamelCase-aware, typo-tolerant) with semantic vector similarity so you can \
1476        search without knowing the exact symbol name. Ranks exact > prefix > semantic > \
1477        substring; functions/structs boosted. Default limit=10."
1478    )]
1479    fn search_code(&self, Parameters(p): Parameters<SearchCodeParams>) -> CallToolResult {
1480        let branch = p
1481            .branch
1482            .as_deref()
1483            .unwrap_or(&self.default_branch)
1484            .to_owned();
1485
1486        // ── Text search ───────────────────────────────────────────────────────
1487        let text_hits = {
1488            let store = match self.store.lock() {
1489                Ok(g) => g,
1490                Err(_) => {
1491                    return CallToolResult::error(vec![Content::text("store mutex poisoned")])
1492                }
1493            };
1494            match super::search::search(&*store, &branch, &p.query, p.limit) {
1495                Ok(h) => h,
1496                Err(e) => {
1497                    return CallToolResult::error(vec![Content::text(format!(
1498                        "search failed: {e}"
1499                    ))])
1500                }
1501            }
1502        };
1503
1504        // ── Semantic search (best-effort, non-blocking) ───────────────────────
1505        // try_lock: never block an MCP call waiting for the background indexer.
1506        let sem_hits: Option<Vec<(String, f32)>> = if let Ok(sem) = self.semantic.try_lock() {
1507            if let SemanticState::Ready { embedder, index } = &*sem {
1508                embedder.embed_one(&p.query).ok().map(|qvec| {
1509                    let limit = p.limit.unwrap_or(10).min(200);
1510                    index.top_k(&qvec, limit * 2)
1511                })
1512            } else {
1513                None
1514            }
1515        } else {
1516            None
1517        };
1518
1519        // ── Merge: resolve semantic IDs to full nodes, deduplicate by node ID ─
1520        // Dedup by node ID (not name) so same-named symbols in different modules
1521        // are both surfaced. Score scales cosine [0.50‥1.0] → [40‥70] so a
1522        // strong semantic hit ranks near prefix (+60) while weak ones stay below
1523        // token matches (+30).
1524        let mut all_hits = text_hits;
1525        let text_ids: std::collections::HashSet<String> = {
1526            // text hits don't carry node IDs; dedup by qualified_name as proxy
1527            all_hits.iter().map(|h| h.qualified_name.clone()).collect()
1528        };
1529
1530        if let Some(scored_ids) = sem_hits {
1531            if !scored_ids.is_empty() {
1532                let ids: Vec<String> = scored_ids.iter().map(|(id, _)| id.clone()).collect();
1533                let sim_map: std::collections::HashMap<String, f32> =
1534                    scored_ids.into_iter().collect();
1535                let store = match self.store.lock() {
1536                    Ok(g) => g,
1537                    Err(_) => {
1538                        return CallToolResult::error(vec![Content::text("store mutex poisoned")])
1539                    }
1540                };
1541                if let Ok(nodes) = store.get_nodes_by_ids(&branch, &ids) {
1542                    for n in nodes {
1543                        if !text_ids.contains(&n.qualified_name) {
1544                            // Map cosine similarity [0.50, 1.0] → score [40, 70]
1545                            let node_id_str = n.id.as_str();
1546                            let sim = sim_map.get(&node_id_str).copied().unwrap_or(0.5);
1547                            let score = (40.0 + (sim - 0.5) * 60.0) as i32;
1548                            all_hits.push(super::search::SearchHit {
1549                                name: n.name,
1550                                qualified_name: n.qualified_name,
1551                                kind: n.kind.to_string(),
1552                                file: n.file.display().to_string(),
1553                                start_line: n.span.start_line,
1554                                score,
1555                            });
1556                        }
1557                    }
1558                }
1559            }
1560        }
1561
1562        let limit = p.limit.unwrap_or(10).min(200);
1563        all_hits.sort_by(|a, b| {
1564            b.score
1565                .cmp(&a.score)
1566                .then_with(|| a.name.len().cmp(&b.name.len()))
1567        });
1568        all_hits.truncate(limit);
1569
1570        CallToolResult::structured(json!({
1571            "query": p.query,
1572            "branch": branch,
1573            "count": all_hits.len(),
1574            "semantic_available": matches!(
1575                self.semantic.try_lock().as_deref(),
1576                Ok(SemanticState::Ready { .. })
1577            ),
1578            "hits": all_hits,
1579        }))
1580    }
1581
1582    /// Generate a guided tour through the repo's important symbols.
1583    #[tool(
1584        description = "Generate a guided tour through the codebase. Without a seed, picks the \
1585        highest-centrality public functions/structs to give a new contributor an entry path. \
1586        With a seed, BFS-walks outward from it along call edges. Returns ordered tour steps \
1587        with rationale per step and a rendered markdown plan."
1588    )]
1589    fn start_tour(&self, Parameters(p): Parameters<StartTourParams>) -> CallToolResult {
1590        let branch = p
1591            .branch
1592            .as_deref()
1593            .unwrap_or(&self.default_branch)
1594            .to_owned();
1595        let store = match self.store.lock() {
1596            Ok(g) => g,
1597            Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1598        };
1599        match super::tour::generate(&*store, &branch, p.seed.as_deref(), p.limit) {
1600            Ok(tour) => {
1601                let markdown = super::tour::render_markdown(&tour);
1602                CallToolResult::structured(json!({
1603                    "branch": tour.branch,
1604                    "seed": tour.seed,
1605                    "components": tour.components,
1606                    "steps": tour.steps,
1607                    "markdown": markdown,
1608                }))
1609            }
1610            Err(e) => CallToolResult::error(vec![Content::text(format!("tour failed: {e}"))]),
1611        }
1612    }
1613
1614    /// Single-entry dispatch — one schema instead of fifteen.
1615    ///
1616    /// Prefer this tool to keep per-turn schema overhead low. All individual
1617    /// tools remain available for direct use; this is an additive alias.
1618    #[tool(description = "Query the GitCortex code knowledge graph. \
1619        action: lookup_symbol | find_callers | find_callees | find_unused_symbols | \
1620        get_subgraph | search_code | start_tour | wiki_symbol | trace_path | \
1621        list_definitions | symbol_context | list_symbols_in_range | graph_stats | ast_search | \
1622        type_hierarchy | find_importers | find_type_usages | module_dependencies | \
1623        get_call_sites | branch_diff_graph. \
1624        params: JSON object with the same fields as the individual tool (name/function_name/\
1625        seed_name/query/file/branch/depth/limit/direction as applicable). \
1626        Returns identical output to the individual tool.")]
1627    fn gcx(&self, Parameters(p): Parameters<GcxDispatchParams>) -> CallToolResult {
1628        let branch_val = p
1629            .params
1630            .get("branch")
1631            .and_then(|v| v.as_str())
1632            .map(|s| s.to_owned());
1633
1634        // Helper: extract a string field from params.
1635        macro_rules! str_field {
1636            ($key:expr) => {
1637                match p.params.get($key).and_then(|v| v.as_str()) {
1638                    Some(s) => s.to_owned(),
1639                    None => {
1640                        return CallToolResult::error(vec![Content::text(format!(
1641                            "gcx dispatch: params.{} is required for action={}",
1642                            $key, p.action
1643                        ))])
1644                    }
1645                }
1646            };
1647        }
1648
1649        match p.action.as_str() {
1650            "lookup_symbol" => self.lookup_symbol(Parameters(LookupSymbolParams {
1651                name: str_field!("name"),
1652                fuzzy: p.params.get("fuzzy").and_then(|v| v.as_bool()),
1653                branch: branch_val,
1654            })),
1655            "find_callers" => self.find_callers(Parameters(FindCallersParams {
1656                function_name: str_field!("function_name"),
1657                depth: p
1658                    .params
1659                    .get("depth")
1660                    .and_then(|v| v.as_u64())
1661                    .map(|n| n as u8),
1662                branch: branch_val,
1663            })),
1664            "find_callees" => self.find_callees(Parameters(FindCalleesParams {
1665                function_name: str_field!("function_name"),
1666                depth: p
1667                    .params
1668                    .get("depth")
1669                    .and_then(|v| v.as_u64())
1670                    .map(|n| n as u8),
1671                branch: branch_val,
1672            })),
1673            "find_unused_symbols" => {
1674                self.find_unused_symbols(Parameters(FindUnusedSymbolsParams {
1675                    kind: p
1676                        .params
1677                        .get("kind")
1678                        .and_then(|v| v.as_str())
1679                        .map(|s| s.to_owned()),
1680                    limit: p
1681                        .params
1682                        .get("limit")
1683                        .and_then(|v| v.as_u64())
1684                        .map(|n| n as usize),
1685                    branch: branch_val,
1686                }))
1687            }
1688            "get_subgraph" => self.get_subgraph(Parameters(GetSubgraphParams {
1689                seed_name: str_field!("seed_name"),
1690                depth: p
1691                    .params
1692                    .get("depth")
1693                    .and_then(|v| v.as_u64())
1694                    .map(|n| n as u8),
1695                direction: p
1696                    .params
1697                    .get("direction")
1698                    .and_then(|v| v.as_str())
1699                    .map(|s| s.to_owned()),
1700                limit: p
1701                    .params
1702                    .get("limit")
1703                    .and_then(|v| v.as_u64())
1704                    .map(|n| n as usize),
1705                branch: branch_val,
1706            })),
1707            "search_code" => self.search_code(Parameters(SearchCodeParams {
1708                query: str_field!("query"),
1709                limit: p
1710                    .params
1711                    .get("limit")
1712                    .and_then(|v| v.as_u64())
1713                    .map(|n| n as usize),
1714                branch: branch_val,
1715            })),
1716            "start_tour" => self.start_tour(Parameters(StartTourParams {
1717                seed: p
1718                    .params
1719                    .get("seed")
1720                    .and_then(|v| v.as_str())
1721                    .map(|s| s.to_owned()),
1722                limit: p
1723                    .params
1724                    .get("limit")
1725                    .and_then(|v| v.as_u64())
1726                    .map(|n| n as usize),
1727                branch: branch_val,
1728            })),
1729            "wiki_symbol" => self.wiki_symbol(Parameters(WikiSymbolParams {
1730                name: str_field!("name"),
1731                branch: branch_val,
1732            })),
1733            "trace_path" => self.trace_path(Parameters(TracePathParams {
1734                from: p
1735                    .params
1736                    .get("from")
1737                    .or_else(|| p.params.get("src"))
1738                    .and_then(|v| v.as_str())
1739                    .map(|s| s.to_owned())
1740                    .unwrap_or_default(),
1741                to: p
1742                    .params
1743                    .get("to")
1744                    .or_else(|| p.params.get("dst"))
1745                    .and_then(|v| v.as_str())
1746                    .map(|s| s.to_owned())
1747                    .unwrap_or_default(),
1748                branch: branch_val,
1749            })),
1750            "list_definitions" => self.list_definitions(Parameters(ListDefinitionsParams {
1751                file: str_field!("file"),
1752                branch: branch_val,
1753            })),
1754            "symbol_context" => self.symbol_context(Parameters(SymbolContextParams {
1755                name: str_field!("name"),
1756                branch: branch_val,
1757            })),
1758            "graph_stats" => self.graph_stats(Parameters(GraphStatsParams { branch: branch_val })),
1759            "type_hierarchy" => self.type_hierarchy(Parameters(TypeHierarchyParams {
1760                name: str_field!("name"),
1761                branch: branch_val,
1762            })),
1763            "find_importers" => self.find_importers(Parameters(FindImportersParams {
1764                name: str_field!("name"),
1765                branch: branch_val,
1766            })),
1767            "get_call_sites" => self.get_call_sites(Parameters(GetCallSitesParams {
1768                name: str_field!("name"),
1769                branch: branch_val,
1770            })),
1771            "find_type_usages" => self.find_type_usages(Parameters(FindTypeUsagesParams {
1772                name: str_field!("name"),
1773                branch: branch_val,
1774            })),
1775            "module_dependencies" => {
1776                self.module_dependencies(Parameters(ModuleDependenciesParams {
1777                    name: str_field!("name"),
1778                    branch: branch_val,
1779                }))
1780            }
1781            "ast_search" => self.ast_search(Parameters(AstSearchParams {
1782                kind: p
1783                    .params
1784                    .get("kind")
1785                    .and_then(|v| v.as_str())
1786                    .map(|s| s.to_owned()),
1787                is_async: p.params.get("is_async").and_then(|v| v.as_bool()),
1788                visibility: p
1789                    .params
1790                    .get("visibility")
1791                    .and_then(|v| v.as_str())
1792                    .map(|s| s.to_owned()),
1793                min_complexity: p
1794                    .params
1795                    .get("min_complexity")
1796                    .and_then(|v| v.as_u64())
1797                    .map(|n| n as u32),
1798                max_complexity: p
1799                    .params
1800                    .get("max_complexity")
1801                    .and_then(|v| v.as_u64())
1802                    .map(|n| n as u32),
1803                name_contains: p
1804                    .params
1805                    .get("name_contains")
1806                    .and_then(|v| v.as_str())
1807                    .map(|s| s.to_owned()),
1808                annotation: p
1809                    .params
1810                    .get("annotation")
1811                    .and_then(|v| v.as_str())
1812                    .map(|s| s.to_owned()),
1813                limit: p
1814                    .params
1815                    .get("limit")
1816                    .and_then(|v| v.as_u64())
1817                    .map(|n| n as usize),
1818                branch: branch_val,
1819            })),
1820            "list_symbols_in_range" => {
1821                self.list_symbols_in_range(Parameters(ListSymbolsInRangeParams {
1822                    file: str_field!("file"),
1823                    start_line: p
1824                        .params
1825                        .get("start_line")
1826                        .and_then(|v| v.as_u64())
1827                        .unwrap_or(1) as u32,
1828                    end_line: p
1829                        .params
1830                        .get("end_line")
1831                        .and_then(|v| v.as_u64())
1832                        .unwrap_or(u32::MAX as u64) as u32,
1833                    branch: branch_val,
1834                }))
1835            }
1836            other => CallToolResult::error(vec![Content::text(format!(
1837                "gcx dispatch: unknown action '{other}'. Valid: lookup_symbol, find_callers, \
1838                find_callees, find_unused_symbols, get_subgraph, search_code, start_tour, \
1839                wiki_symbol, trace_path, list_definitions, symbol_context, list_symbols_in_range, \
1840                graph_stats, ast_search, type_hierarchy, find_importers, find_type_usages, \
1841                module_dependencies, get_call_sites"
1842            ))]),
1843        }
1844    }
1845}
1846
1847// ── Prompt parameter types ────────────────────────────────────────────────────
1848
1849#[derive(Debug, Deserialize, JsonSchema)]
1850pub struct DetectImpactParams {
1851    /// Comma-separated list of changed file paths (repo-relative).
1852    pub changed_files: String,
1853    /// Branch to query (defaults to "main").
1854    pub branch: Option<String>,
1855}
1856
1857#[derive(Debug, Deserialize, JsonSchema)]
1858pub struct GenerateMapParams {
1859    /// Branch to document (defaults to "main").
1860    pub branch: Option<String>,
1861}
1862
1863// ── Prompt implementations ────────────────────────────────────────────────────
1864
1865#[prompt_router]
1866impl GitCortexServer {
1867    /// Analyse the blast radius of changed files before committing.
1868    /// Walks the call graph from changed symbols to find all downstream callers
1869    /// and produces a risk assessment (LOW / MEDIUM / HIGH / CRITICAL).
1870    #[prompt(
1871        name = "detect_impact",
1872        description = "Pre-commit impact analysis — maps changed files to affected callers and scores risk"
1873    )]
1874    fn detect_impact(&self, Parameters(p): Parameters<DetectImpactParams>) -> GetPromptResult {
1875        let branch = p.branch.as_deref().unwrap_or("main");
1876        let files = p.changed_files.trim().to_owned();
1877
1878        let user_msg = format!(
1879            r#"I am about to commit changes to these files on branch `{branch}`:
1880
1881{files}
1882
1883Please analyse the blast radius of these changes using the GitCortex knowledge graph:
1884
18851. For each changed file call `list_definitions` to identify which symbols were likely touched.
18862. For each key function or struct, call `find_callers` to find direct callers.
18873. Repeat `find_callers` one level deeper for any HIGH-traffic callers.
18884. Summarise your findings as:
1889   - **Changed symbols**: list each modified function/struct with its file and line.
1890   - **Direct callers**: who calls the changed code.
1891   - **Transitive callers**: notable callers two hops away.
1892   - **Risk level**: LOW / MEDIUM / HIGH / CRITICAL with a one-line justification.
1893   - **Recommended actions**: tests to run, reviewers to notify, docs to update.
1894"#
1895        );
1896
1897        GetPromptResult::new(vec![PromptMessage::new_text(
1898            PromptMessageRole::User,
1899            user_msg,
1900        )])
1901        .with_description("Impact analysis of staged changes using the call graph")
1902    }
1903
1904    /// Generate a Mermaid architecture diagram from the knowledge graph.
1905    /// Summarises modules, key structs/traits, and their relationships.
1906    #[prompt(
1907        name = "generate_map",
1908        description = "Architecture documentation — produces a Mermaid diagram of modules, types, and key relationships"
1909    )]
1910    fn generate_map(&self, Parameters(p): Parameters<GenerateMapParams>) -> GetPromptResult {
1911        let branch = p.branch.as_deref().unwrap_or("main");
1912
1913        let user_msg = format!(
1914            r#"Generate an architecture map of this codebase on branch `{branch}` using GitCortex.
1915
1916Steps:
19171. Call `list_definitions` on each major source file to collect modules, structs, traits, and functions.
19182. Call `find_callers` on the top-level entry points to understand key execution flows.
19193. Call `lookup_symbol` on core traits to find all their implementors.
1920
1921Then produce:
1922
1923## Architecture Overview
1924A prose summary (3–5 sentences) of what this codebase does and how it is structured.
1925
1926## Module Map
1927```mermaid
1928graph TD
1929  %% Add nodes for each module/crate and edges for depends-on relationships
1930```
1931
1932## Key Types
1933A table: | Type | Kind | Responsibility | Implemented by |
1934
1935## Core Flows
1936Numbered list of the 2–4 most important execution paths (entry point → key functions → output).
1937
1938## Dependency Notes
1939Any circular dependencies, large fan-outs, or architectural concerns visible in the graph.
1940"#
1941        );
1942
1943        GetPromptResult::new(vec![PromptMessage::new_text(
1944            PromptMessageRole::User,
1945            user_msg,
1946        )])
1947        .with_description(
1948            "Architecture documentation with Mermaid diagram from the knowledge graph",
1949        )
1950    }
1951}
1952
1953// ── Combined ServerHandler (tools + prompts) ──────────────────────────────────
1954
1955#[tool_handler(router = self.active_tool_router())]
1956#[prompt_handler(router = Self::prompt_router())]
1957impl rmcp::ServerHandler for GitCortexServer {
1958    fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
1959        self.active_tool_router().get(name).cloned()
1960    }
1961}
1962
1963// ── Git diff helpers ──────────────────────────────────────────────────────────
1964
1965fn run_git_diff(repo_root: &Path, args: &[&str]) -> Option<String> {
1966    let out = std::process::Command::new("git")
1967        .args(args)
1968        .current_dir(repo_root)
1969        .output()
1970        .ok()?;
1971    if out.status.success() {
1972        String::from_utf8(out.stdout).ok()
1973    } else {
1974        None
1975    }
1976}
1977
1978/// Parse unified diff text into `(repo_relative_file_path, [(start_line, end_line)])`.
1979fn parse_diff_hunks(diff: &str) -> Vec<(String, Vec<(u32, u32)>)> {
1980    let mut result: Vec<(String, Vec<(u32, u32)>)> = Vec::new();
1981    let mut cur_file: Option<String> = None;
1982    let mut cur_hunks: Vec<(u32, u32)> = Vec::new();
1983
1984    for line in diff.lines() {
1985        if let Some(path) = line.strip_prefix("+++ b/") {
1986            if let Some(f) = cur_file.take() {
1987                if !cur_hunks.is_empty() {
1988                    result.push((f, std::mem::take(&mut cur_hunks)));
1989                }
1990            }
1991            cur_file = Some(path.to_owned());
1992        } else if line.starts_with("@@ ") {
1993            if let Some(hunk) = parse_hunk_header(line) {
1994                cur_hunks.push(hunk);
1995            }
1996        }
1997    }
1998    if let Some(f) = cur_file {
1999        if !cur_hunks.is_empty() {
2000            result.push((f, cur_hunks));
2001        }
2002    }
2003    result
2004}
2005
2006/// Extract the new-file line range from a unified diff hunk header.
2007/// `@@ -old_start[,old_count] +new_start[,new_count] @@`
2008fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
2009    let rest = line.strip_prefix("@@ ")?;
2010    let plus_pos = rest.find(" +")?;
2011    let new_part = &rest[plus_pos + 2..];
2012    let end = new_part.find(' ').unwrap_or(new_part.len());
2013    let range = &new_part[..end];
2014    if let Some(comma) = range.find(',') {
2015        let start: u32 = range[..comma].parse().ok()?;
2016        let count: u32 = range[comma + 1..].parse().ok()?;
2017        Some((start, start + count.saturating_sub(1)))
2018    } else {
2019        let start: u32 = range.parse().ok()?;
2020        Some((start, start))
2021    }
2022}