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