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