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 Pending,
20 Ready {
22 branch: String,
23 embedder: Box<Embedder>,
24 index: Box<SemanticIndex>,
25 },
26 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#[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 response_budget: usize,
58 pub semantic: Arc<Mutex<SemanticState>>,
63 staleness_cache: Arc<Mutex<Option<(Instant, String)>>>,
66}
67
68const DEFAULT_RESPONSE_BUDGET: usize = 2000;
70const 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 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 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; 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 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 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 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_router]
302impl GitCortexServer {
303 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 let unused_count = store
1433 .find_unused_symbols(&branch, None)
1434 .map(|v| v.len())
1435 .unwrap_or(0);
1436
1437 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 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 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 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 #[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 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 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_router]
1848impl GitCortexServer {
1849 #[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 #[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#[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}