Skip to main content

gitcortex_mcp/mcp/
tools.rs

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