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