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 AstSearchParams {
233 pub kind: Option<String>,
236 pub is_async: Option<bool>,
238 pub visibility: Option<String>,
240 pub min_complexity: Option<u32>,
243 pub max_complexity: Option<u32>,
245 pub name_contains: Option<String>,
247 pub annotation: Option<String>,
251 pub limit: Option<usize>,
253 pub branch: Option<String>,
254}
255
256#[derive(Clone)]
261pub struct GitCortexServer {
262 store: Arc<Mutex<KuzuGraphStore>>,
263 repo_root: PathBuf,
264 default_branch: String,
265 compact: bool,
266 response_budget: usize,
271 pub semantic: Arc<Mutex<SemanticState>>,
276}
277
278const DEFAULT_RESPONSE_BUDGET: usize = 2000;
280const MIN_RESPONSE_BUDGET: usize = 400;
282
283impl GitCortexServer {
284 pub fn new(repo_root: &Path) -> anyhow::Result<Self> {
285 Self::new_with_mode(repo_root, false)
286 }
287
288 pub fn new_with_mode(repo_root: &Path, compact: bool) -> anyhow::Result<Self> {
289 let store = KuzuGraphStore::open(repo_root)?;
290 let default_branch = detect_current_branch(repo_root).unwrap_or_else(|| "main".into());
291 let response_budget = std::env::var("GCX_RESPONSE_BUDGET")
292 .ok()
293 .and_then(|s| s.parse::<usize>().ok())
294 .unwrap_or(DEFAULT_RESPONSE_BUDGET)
295 .max(MIN_RESPONSE_BUDGET);
296 Ok(Self {
297 store: Arc::new(Mutex::new(store)),
298 repo_root: repo_root.to_owned(),
299 default_branch,
300 compact,
301 response_budget,
302 semantic: Arc::new(Mutex::new(SemanticState::Pending)),
303 })
304 }
305
306 fn budget_items(&self, items: Vec<serde_json::Value>) -> (Vec<serde_json::Value>, bool) {
312 let budget_bytes = self.response_budget * 4;
313 let mut kept: Vec<serde_json::Value> = Vec::with_capacity(items.len());
314 let mut used = 0usize;
315 let total = items.len();
316 for item in items {
317 let sz = item.to_string().len() + 2; if !kept.is_empty() && used + sz > budget_bytes {
319 break;
320 }
321 used += sz;
322 kept.push(item);
323 }
324 let truncated = kept.len() < total;
325 (kept, truncated)
326 }
327
328 pub fn semantic_context(
330 &self,
331 ) -> (
332 Arc<Mutex<SemanticState>>,
333 Arc<Mutex<KuzuGraphStore>>,
334 String,
335 ) {
336 (
337 self.semantic.clone(),
338 self.store.clone(),
339 self.default_branch.clone(),
340 )
341 }
342
343 fn active_tool_router(&self) -> ToolRouter<Self> {
344 let mut router = Self::tool_router();
345 if self.compact {
346 for name in [
347 "lookup_symbol",
348 "find_callers",
349 "symbol_context",
350 "list_definitions",
351 "branch_diff_graph",
352 "detect_changes",
353 "find_callees",
354 "find_implementors",
355 "trace_path",
356 "list_symbols_in_range",
357 "find_unused_symbols",
358 "get_subgraph",
359 "wiki_symbol",
360 "search_code",
361 "start_tour",
362 ] {
363 router.disable_route(name);
364 }
365 }
366 router
367 }
368}
369
370fn sig_line(n: &gitcortex_core::graph::Node) -> String {
374 const MAX: usize = 120;
375 let first = n
376 .metadata
377 .definition
378 .signature
379 .lines()
380 .next()
381 .unwrap_or("")
382 .trim();
383 if first.chars().count() > MAX {
384 let truncated: String = first.chars().take(MAX).collect();
385 format!("{truncated}…")
386 } else {
387 first.to_owned()
388 }
389}
390
391fn parse_node_kind(s: &str) -> Option<NodeKind> {
393 Some(match s {
394 "folder" => NodeKind::Folder,
395 "file" => NodeKind::File,
396 "module" => NodeKind::Module,
397 "struct" => NodeKind::Struct,
398 "enum" => NodeKind::Enum,
399 "trait" => NodeKind::Trait,
400 "interface" => NodeKind::Interface,
401 "type_alias" => NodeKind::TypeAlias,
402 "function" => NodeKind::Function,
403 "method" => NodeKind::Method,
404 "property" => NodeKind::Property,
405 "constant" => NodeKind::Constant,
406 "macro" => NodeKind::Macro,
407 "annotation" => NodeKind::Annotation,
408 "enum_member" => NodeKind::EnumMember,
409 _ => return None,
410 })
411}
412
413fn parse_visibility(s: &str) -> Option<Visibility> {
415 Some(match s {
416 "pub" => Visibility::Pub,
417 "pub_crate" => Visibility::PubCrate,
418 "private" => Visibility::Private,
419 _ => return None,
420 })
421}
422
423fn detect_current_branch(repo_root: &Path) -> Option<String> {
424 let out = std::process::Command::new("git")
425 .args(["symbolic-ref", "--short", "HEAD"])
426 .current_dir(repo_root)
427 .output()
428 .ok()?;
429 if out.status.success() {
430 let s = String::from_utf8(out.stdout).ok()?;
431 let b = s.trim().to_owned();
432 if b.is_empty() {
433 None
434 } else {
435 Some(b)
436 }
437 } else {
438 None
439 }
440}
441
442#[tool_router]
445impl GitCortexServer {
446 #[tool(
448 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."
449 )]
450 fn lookup_symbol(&self, Parameters(p): Parameters<LookupSymbolParams>) -> CallToolResult {
451 let branch = p
452 .branch
453 .as_deref()
454 .unwrap_or(&self.default_branch)
455 .to_owned();
456 let fuzzy = p.fuzzy.unwrap_or(false);
457 let store = match self.store.lock() {
458 Ok(g) => g,
459 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
460 };
461 match store.lookup_symbol(&branch, &p.name, fuzzy) {
462 Ok(nodes) => {
463 let items: Vec<_> = nodes
464 .iter()
465 .map(|n| {
466 json!({
467 "id": n.id.as_str(),
468 "kind": n.kind.to_string(),
469 "name": n.name,
470 "qualified_name": n.qualified_name,
471 "file": n.file.display().to_string(),
472 "start_line": n.span.start_line,
473 "end_line": n.span.end_line,
474 "visibility": format!("{:?}", n.metadata.visibility),
475 "is_async": n.metadata.is_async,
476 "is_unsafe": n.metadata.is_unsafe,
477 })
478 })
479 .collect();
480 let (items, _) = self.budget_items(items);
481 CallToolResult::structured(json!(items))
482 }
483 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
484 }
485 }
486
487 #[tool(
489 description = "Find callers of a function. depth=1 (default) = direct callers; \
490 depth=2..5 = multi-hop. Results capped per hop; total count always returned."
491 )]
492 fn find_callers(&self, Parameters(p): Parameters<FindCallersParams>) -> CallToolResult {
493 let branch = p
494 .branch
495 .as_deref()
496 .unwrap_or(&self.default_branch)
497 .to_owned();
498 let depth = p.depth.unwrap_or(1).max(1);
499 let store = match self.store.lock() {
500 Ok(g) => g,
501 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
502 };
503
504 const MAX_CALLERS: usize = 25;
507 const MAX_PER_HOP: usize = 15;
508 if depth == 1 {
509 match store.find_callers(&branch, &p.function_name) {
510 Ok(nodes) => {
511 let total = nodes.len();
512 let items: Vec<_> = nodes
513 .iter()
514 .take(MAX_CALLERS)
515 .map(|n| {
516 json!({
517 "hop": 1,
518 "kind": n.kind.to_string(),
519 "name": n.name,
520 "qualified_name": n.qualified_name,
521 "file": n.file.display().to_string(),
522 "start_line": n.span.start_line,
523 "signature": sig_line(n),
527 })
528 })
529 .collect();
530 let (items, budget_trunc) = self.budget_items(items);
531 let risk = match total {
532 0..=2 => "LOW",
533 3..=10 => "MEDIUM",
534 11..=30 => "HIGH",
535 _ => "CRITICAL",
536 };
537 CallToolResult::structured(json!({
538 "summary": format!("{total} caller(s) — risk {risk}{}",
539 if total > items.len() {
540 format!(", showing top {}", items.len())
541 } else { String::new() }),
542 "function": p.function_name,
543 "depth": 1,
544 "risk_level": risk,
545 "total_callers": total,
546 "returned": items.len(),
547 "truncated": total > items.len() || budget_trunc,
548 "callers": items,
549 }))
550 }
551 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
552 }
553 } else {
554 match store.find_callers_deep(&branch, &p.function_name, depth) {
555 Ok(result) => {
556 let hops: Vec<_> = result
557 .hops
558 .iter()
559 .enumerate()
560 .map(|(i, nodes)| {
561 let total = nodes.len();
562 let callers: Vec<_> = nodes
563 .iter()
564 .take(MAX_PER_HOP)
565 .map(|n| {
566 json!({
567 "kind": n.kind.to_string(),
568 "name": n.name,
569 "qualified_name": n.qualified_name,
570 "file": n.file.display().to_string(),
571 "start_line": n.span.start_line,
572 "signature": sig_line(n),
573 })
574 })
575 .collect();
576 json!({
577 "hop": i + 1,
578 "total": total,
579 "truncated": total > MAX_PER_HOP,
580 "callers": callers,
581 })
582 })
583 .collect();
584 CallToolResult::structured(json!({
585 "function": p.function_name,
586 "depth": depth,
587 "risk_level": result.risk_level,
588 "hops": hops,
589 }))
590 }
591 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
592 }
593 }
594 }
595
596 #[tool(
598 description = "Get a complete picture of a symbol in one call: where it's defined, \
599 what calls it (callers), what it calls (callees), and which code references it as a type. \
600 Use this instead of chaining lookup_symbol + find_callers separately."
601 )]
602 fn symbol_context(&self, Parameters(p): Parameters<SymbolContextParams>) -> CallToolResult {
603 let branch = p
604 .branch
605 .as_deref()
606 .unwrap_or(&self.default_branch)
607 .to_owned();
608 let store = match self.store.lock() {
609 Ok(g) => g,
610 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
611 };
612 match store.symbol_context(&branch, &p.name) {
613 Ok(ctx) => {
614 let node_json = |n: &gitcortex_core::graph::Node| {
615 json!({
616 "kind": n.kind.to_string(),
617 "name": n.name,
618 "qualified_name": n.qualified_name,
619 "file": n.file.display().to_string(),
620 "start_line": n.span.start_line,
621 })
622 };
623 CallToolResult::structured(json!({
624 "definition": {
625 "kind": ctx.definition.kind.to_string(),
626 "name": ctx.definition.name,
627 "qualified_name": ctx.definition.qualified_name,
628 "file": ctx.definition.file.display().to_string(),
629 "start_line": ctx.definition.span.start_line,
630 "end_line": ctx.definition.span.end_line,
631 "visibility": format!("{:?}", ctx.definition.metadata.visibility),
632 "is_async": ctx.definition.metadata.is_async,
633 "complexity": ctx.definition.metadata.lld.complexity,
634 },
635 "callers": ctx.callers.iter().map(node_json).collect::<Vec<_>>(),
636 "callees": ctx.callees.iter().map(node_json).collect::<Vec<_>>(),
637 "used_by": ctx.used_by.iter().map(node_json).collect::<Vec<_>>(),
638 }))
639 }
640 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
641 }
642 }
643
644 #[tool(
646 description = "List all functions, structs, traits, and other definitions in a source file, ordered by line number."
647 )]
648 fn list_definitions(&self, Parameters(p): Parameters<ListDefinitionsParams>) -> CallToolResult {
649 let branch = p
650 .branch
651 .as_deref()
652 .unwrap_or(&self.default_branch)
653 .to_owned();
654 let store = match self.store.lock() {
655 Ok(g) => g,
656 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
657 };
658 match store.list_definitions(&branch, Path::new(&p.file)) {
659 Ok(nodes) => {
660 let items: Vec<_> = nodes
661 .iter()
662 .map(|n| {
663 json!({
664 "kind": n.kind.to_string(),
665 "name": n.name,
666 "qualified_name": n.qualified_name,
667 "start_line": n.span.start_line,
668 "end_line": n.span.end_line,
669 "loc": n.metadata.loc,
670 "visibility": format!("{:?}", n.metadata.visibility),
671 "is_async": n.metadata.is_async,
672 })
673 })
674 .collect();
675 let (items, _) = self.budget_items(items);
676 CallToolResult::structured(json!(items))
677 }
678 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
679 }
680 }
681
682 #[tool(
684 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."
685 )]
686 fn graph_stats(&self, Parameters(p): Parameters<GraphStatsParams>) -> CallToolResult {
687 let branch = p
688 .branch
689 .as_deref()
690 .unwrap_or(&self.default_branch)
691 .to_owned();
692 let store = match self.store.lock() {
693 Ok(g) => g,
694 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
695 };
696 match store.graph_stats(&branch) {
697 Ok(stats) => {
698 let to_obj = |pairs: &[(String, u64)]| -> serde_json::Value {
699 json!(pairs
700 .iter()
701 .map(|(k, c)| json!({ "kind": k, "count": c }))
702 .collect::<Vec<_>>())
703 };
704 CallToolResult::structured(json!({
705 "branch": branch,
706 "total_nodes": stats.total_nodes,
707 "total_edges": stats.total_edges,
708 "nodes_by_kind": to_obj(&stats.nodes_by_kind),
709 "edges_by_kind": to_obj(&stats.edges_by_kind),
710 }))
711 }
712 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
713 }
714 }
715
716 #[tool(
718 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."
719 )]
720 fn ast_search(&self, Parameters(p): Parameters<AstSearchParams>) -> CallToolResult {
721 let branch = p
722 .branch
723 .as_deref()
724 .unwrap_or(&self.default_branch)
725 .to_owned();
726 let limit = p.limit.unwrap_or(30).min(200);
727
728 let kind = p.kind.as_deref().and_then(parse_node_kind);
729 if p.kind.is_some() && kind.is_none() {
731 return CallToolResult::error(vec![Content::text(format!(
732 "unknown kind '{}'. Valid: function, method, struct, enum, trait, \
733 interface, type_alias, property, constant, macro, annotation, \
734 enum_member, module, file, folder",
735 p.kind.as_deref().unwrap_or("")
736 ))]);
737 }
738 let visibility = p.visibility.as_deref().and_then(parse_visibility);
739 if p.visibility.is_some() && visibility.is_none() {
740 return CallToolResult::error(vec![Content::text(
741 "unknown visibility. Valid: pub, pub_crate, private".to_owned(),
742 )]);
743 }
744
745 let filter = AttributeFilter {
746 kind,
747 is_async: p.is_async,
748 visibility,
749 min_complexity: p.min_complexity,
750 max_complexity: p.max_complexity,
751 name_contains: p.name_contains.clone(),
752 annotation: p.annotation.clone(),
753 };
754
755 if filter.is_empty() {
756 return CallToolResult::error(vec![Content::text(
757 "ast_search needs at least one filter (kind, is_async, visibility, \
758 complexity bound, name_contains, or annotation)"
759 .to_owned(),
760 )]);
761 }
762
763 let store = match self.store.lock() {
764 Ok(g) => g,
765 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
766 };
767 match store.search_by_attributes(&branch, &filter, limit) {
768 Ok(nodes) => {
769 let items: Vec<_> = nodes
770 .iter()
771 .map(|n| {
772 json!({
773 "kind": n.kind.to_string(),
774 "name": n.name,
775 "qualified_name": n.qualified_name,
776 "file": n.file.display().to_string(),
777 "start_line": n.span.start_line,
778 "visibility": format!("{:?}", n.metadata.visibility),
779 "is_async": n.metadata.is_async,
780 "complexity": n.metadata.lld.complexity,
781 "annotations": n.metadata.annotations,
782 })
783 })
784 .collect();
785 let (items, truncated) = self.budget_items(items);
786 CallToolResult::structured(json!({
787 "branch": branch,
788 "results": items,
789 "returned": items.len(),
790 "truncated": truncated,
791 }))
792 }
793 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
794 }
795 }
796
797 #[tool(
799 description = "Show what nodes were added or removed between two branches. Useful for understanding what changed in a feature branch vs main."
800 )]
801 fn branch_diff_graph(&self, Parameters(p): Parameters<BranchDiffParams>) -> CallToolResult {
802 let store = match self.store.lock() {
803 Ok(g) => g,
804 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
805 };
806 match store.branch_diff(&p.from_branch, &p.to_branch) {
807 Ok(diff) => {
808 let added: Vec<_> = diff
809 .added_nodes
810 .iter()
811 .map(|n| {
812 json!({
813 "kind": n.kind.to_string(),
814 "name": n.name,
815 "file": n.file.display().to_string(),
816 "start_line": n.span.start_line,
817 })
818 })
819 .collect();
820
821 let from_nodes = store.list_all_nodes(&p.from_branch).unwrap_or_default();
823 let from_map: std::collections::HashMap<_, _> =
824 from_nodes.iter().map(|n| (n.id.clone(), n)).collect();
825 let removed: Vec<_> = diff
826 .removed_node_ids
827 .iter()
828 .filter_map(|id| from_map.get(id))
829 .map(|n| {
830 json!({
831 "kind": n.kind.to_string(),
832 "name": n.name,
833 "file": n.file.display().to_string(),
834 "start_line": n.span.start_line,
835 })
836 })
837 .collect();
838
839 CallToolResult::structured(json!({
840 "from": p.from_branch,
841 "to": p.to_branch,
842 "added_nodes": added,
843 "removed_nodes": removed,
844 }))
845 }
846 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
847 }
848 }
849
850 #[tool(
852 description = "Map the current git diff (staged changes, or HEAD diff if nothing is staged) \
853 to the indexed symbol graph. Returns which functions/structs were changed, their direct callers, \
854 and a risk level. Use this before committing to understand blast radius automatically."
855 )]
856 fn detect_changes(&self, Parameters(p): Parameters<DetectChangesParams>) -> CallToolResult {
857 let branch = p
858 .branch
859 .as_deref()
860 .unwrap_or(&self.default_branch)
861 .to_owned();
862
863 let diff_text = run_git_diff(&self.repo_root, &["diff", "--staged"])
864 .filter(|s| !s.trim().is_empty())
865 .or_else(|| run_git_diff(&self.repo_root, &["diff", "HEAD"]))
866 .unwrap_or_default();
867
868 if diff_text.trim().is_empty() {
869 return CallToolResult::success(vec![Content::text(
870 "No staged or unstaged changes detected.",
871 )]);
872 }
873
874 let hunks = parse_diff_hunks(&diff_text);
875 let store = match self.store.lock() {
876 Ok(g) => g,
877 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
878 };
879
880 let mut changed_symbols: Vec<serde_json::Value> = Vec::new();
881 let mut total_affected: usize = 0;
882
883 for (file_path, ranges) in &hunks {
884 let path = PathBuf::from(file_path);
885 let definitions = match store.list_definitions(&branch, &path) {
886 Ok(d) => d,
887 Err(_) => continue,
888 };
889 for node in &definitions {
890 let overlaps = ranges
891 .iter()
892 .any(|(s, e)| node.span.start_line <= *e && node.span.end_line >= *s);
893 if !overlaps {
894 continue;
895 }
896 let callers = store.find_callers(&branch, &node.name).unwrap_or_default();
897 let caller_names: Vec<&str> = callers.iter().map(|c| c.name.as_str()).collect();
898 total_affected += 1 + caller_names.len();
899 changed_symbols.push(json!({
900 "kind": node.kind.to_string(),
901 "name": node.name,
902 "file": file_path,
903 "start_line": node.span.start_line,
904 "end_line": node.span.end_line,
905 "callers": caller_names,
906 }));
907 }
908 }
909
910 if changed_symbols.is_empty() {
911 return CallToolResult::success(vec![Content::text(
912 "Changed lines do not overlap with any indexed symbols.",
913 )]);
914 }
915
916 let risk_level = match total_affected {
917 0..=5 => "LOW",
918 6..=20 => "MEDIUM",
919 21..=50 => "HIGH",
920 _ => "CRITICAL",
921 };
922
923 CallToolResult::structured(json!({
924 "risk_level": risk_level,
925 "total_affected": total_affected,
926 "changed_symbols": changed_symbols,
927 }))
928 }
929
930 #[tool(
932 description = "Find all functions/methods that the named function calls. \
933 Inverse of find_callers — traces forward (downstream). Use depth=1..5 to walk multiple hops. \
934 Returns callees grouped by hop distance."
935 )]
936 fn find_callees(&self, Parameters(p): Parameters<FindCalleesParams>) -> CallToolResult {
937 let branch = p
938 .branch
939 .as_deref()
940 .unwrap_or(&self.default_branch)
941 .to_owned();
942 let depth = p.depth.unwrap_or(1).max(1);
943 let store = match self.store.lock() {
944 Ok(g) => g,
945 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
946 };
947 match store.find_callees(&branch, &p.function_name, depth) {
948 Ok(result) => {
949 let hops: Vec<_> = result
950 .hops
951 .iter()
952 .enumerate()
953 .map(|(i, nodes)| {
954 let callees: Vec<_> = nodes
955 .iter()
956 .map(|n| {
957 json!({
958 "kind": n.kind.to_string(),
959 "name": n.name,
960 "qualified_name": n.qualified_name,
961 "file": n.file.display().to_string(),
962 "start_line": n.span.start_line,
963 })
964 })
965 .collect();
966 json!({ "hop": i + 1, "callees": callees })
967 })
968 .collect();
969 CallToolResult::structured(json!({
970 "function": p.function_name,
971 "depth": depth,
972 "hops": hops,
973 }))
974 }
975 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
976 }
977 }
978
979 #[tool(
981 description = "Find all concrete types (structs, classes) that implement or inherit the named \
982 trait or interface. Works for Rust traits, Java/TypeScript interfaces, and Go structural types."
983 )]
984 fn find_implementors(
985 &self,
986 Parameters(p): Parameters<FindImplementorsParams>,
987 ) -> CallToolResult {
988 let branch = p
989 .branch
990 .as_deref()
991 .unwrap_or(&self.default_branch)
992 .to_owned();
993 let store = match self.store.lock() {
994 Ok(g) => g,
995 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
996 };
997 match store.find_implementors(&branch, &p.trait_name) {
998 Ok(nodes) => {
999 let items: Vec<_> = nodes
1000 .iter()
1001 .map(|n| {
1002 json!({
1003 "kind": n.kind.to_string(),
1004 "name": n.name,
1005 "qualified_name": n.qualified_name,
1006 "file": n.file.display().to_string(),
1007 "start_line": n.span.start_line,
1008 })
1009 })
1010 .collect();
1011 let (items, truncated) = self.budget_items(items);
1012 CallToolResult::structured(json!({
1013 "trait": p.trait_name,
1014 "implementors": items,
1015 "truncated": truncated,
1016 }))
1017 }
1018 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1019 }
1020 }
1021
1022 #[tool(
1024 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)."
1025 )]
1026 fn module_dependencies(
1027 &self,
1028 Parameters(p): Parameters<ModuleDependenciesParams>,
1029 ) -> CallToolResult {
1030 let branch = p
1031 .branch
1032 .as_deref()
1033 .unwrap_or(&self.default_branch)
1034 .to_owned();
1035 let store = match self.store.lock() {
1036 Ok(g) => g,
1037 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1038 };
1039 match store.module_dependencies(&branch, &p.name) {
1040 Ok(nodes) => {
1041 let items: Vec<_> = nodes
1042 .iter()
1043 .map(|n| {
1044 json!({
1045 "name": n.name,
1046 "file": n.file.display().to_string(),
1047 })
1048 })
1049 .collect();
1050 CallToolResult::structured(json!({
1051 "module": p.name,
1052 "depends_on": items,
1053 }))
1054 }
1055 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1056 }
1057 }
1058
1059 #[tool(
1061 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."
1062 )]
1063 fn find_type_usages(&self, Parameters(p): Parameters<FindTypeUsagesParams>) -> CallToolResult {
1064 let branch = p
1065 .branch
1066 .as_deref()
1067 .unwrap_or(&self.default_branch)
1068 .to_owned();
1069 let store = match self.store.lock() {
1070 Ok(g) => g,
1071 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1072 };
1073 match store.find_type_usages(&branch, &p.name) {
1074 Ok(nodes) => {
1075 let items: Vec<_> = nodes
1076 .iter()
1077 .map(|n| {
1078 json!({
1079 "kind": n.kind.to_string(),
1080 "name": n.name,
1081 "qualified_name": n.qualified_name,
1082 "file": n.file.display().to_string(),
1083 "start_line": n.span.start_line,
1084 })
1085 })
1086 .collect();
1087 let (items, truncated) = self.budget_items(items);
1088 CallToolResult::structured(json!({
1089 "type": p.name,
1090 "usages": items,
1091 "truncated": truncated,
1092 }))
1093 }
1094 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1095 }
1096 }
1097
1098 #[tool(
1100 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."
1101 )]
1102 fn get_call_sites(&self, Parameters(p): Parameters<GetCallSitesParams>) -> CallToolResult {
1103 let branch = p
1104 .branch
1105 .as_deref()
1106 .unwrap_or(&self.default_branch)
1107 .to_owned();
1108 let store = match self.store.lock() {
1109 Ok(g) => g,
1110 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1111 };
1112 match store.find_call_sites(&branch, &p.name) {
1113 Ok(sites) => {
1114 let items: Vec<_> = sites
1115 .iter()
1116 .map(|s| {
1117 json!({
1118 "caller": s.caller.name,
1119 "caller_kind": s.caller.kind.to_string(),
1120 "file": s.caller.file.display().to_string(),
1121 "line": s.line,
1122 "caller_start_line": s.caller.span.start_line,
1123 })
1124 })
1125 .collect();
1126 let total = items.len();
1127 let (items, truncated) = self.budget_items(items);
1128 CallToolResult::structured(json!({
1129 "function": p.name,
1130 "call_sites": items,
1131 "count": total,
1132 "returned": items.len(),
1133 "truncated": truncated,
1134 }))
1135 }
1136 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1137 }
1138 }
1139
1140 #[tool(
1142 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."
1143 )]
1144 fn find_importers(&self, Parameters(p): Parameters<FindImportersParams>) -> CallToolResult {
1145 let branch = p
1146 .branch
1147 .as_deref()
1148 .unwrap_or(&self.default_branch)
1149 .to_owned();
1150 let store = match self.store.lock() {
1151 Ok(g) => g,
1152 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1153 };
1154 match store.find_importers(&branch, &p.name) {
1155 Ok(nodes) => {
1156 let items: Vec<_> = nodes
1157 .iter()
1158 .map(|n| {
1159 json!({
1160 "kind": n.kind.to_string(),
1161 "name": n.name,
1162 "qualified_name": n.qualified_name,
1163 "file": n.file.display().to_string(),
1164 "start_line": n.span.start_line,
1165 })
1166 })
1167 .collect();
1168 let (items, truncated) = self.budget_items(items);
1169 CallToolResult::structured(json!({
1170 "symbol": p.name,
1171 "importers": items,
1172 "truncated": truncated,
1173 }))
1174 }
1175 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1176 }
1177 }
1178
1179 #[tool(
1181 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."
1182 )]
1183 fn type_hierarchy(&self, Parameters(p): Parameters<TypeHierarchyParams>) -> CallToolResult {
1184 let branch = p
1185 .branch
1186 .as_deref()
1187 .unwrap_or(&self.default_branch)
1188 .to_owned();
1189 let store = match self.store.lock() {
1190 Ok(g) => g,
1191 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1192 };
1193 match store.type_hierarchy(&branch, &p.name) {
1194 Ok(h) => {
1195 let to_items = |nodes: &[gitcortex_core::graph::Node]| -> serde_json::Value {
1196 json!(nodes
1197 .iter()
1198 .map(|n| json!({
1199 "kind": n.kind.to_string(),
1200 "name": n.name,
1201 "qualified_name": n.qualified_name,
1202 "file": n.file.display().to_string(),
1203 "start_line": n.span.start_line,
1204 }))
1205 .collect::<Vec<_>>())
1206 };
1207 CallToolResult::structured(json!({
1208 "type": p.name,
1209 "supertypes": to_items(&h.supertypes),
1210 "subtypes": to_items(&h.subtypes),
1211 }))
1212 }
1213 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1214 }
1215 }
1216
1217 #[tool(
1219 description = "Find a call path from one function to another. Returns the shortest chain of \
1220 calls connecting `from` to `to`. Returns an empty array if no path exists within 6 hops. \
1221 Most useful for debugging 'how can A reach B?' questions."
1222 )]
1223 fn trace_path(&self, Parameters(p): Parameters<TracePathParams>) -> CallToolResult {
1224 let branch = p
1225 .branch
1226 .as_deref()
1227 .unwrap_or(&self.default_branch)
1228 .to_owned();
1229 let store = match self.store.lock() {
1230 Ok(g) => g,
1231 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1232 };
1233 match store.trace_path(&branch, &p.from, &p.to) {
1234 Ok(path) => {
1235 let nodes: Vec<_> = path
1236 .iter()
1237 .map(|n| {
1238 json!({
1239 "kind": n.kind.to_string(),
1240 "name": n.name,
1241 "file": n.file.display().to_string(),
1242 "start_line": n.span.start_line,
1243 })
1244 })
1245 .collect();
1246 CallToolResult::structured(json!({
1247 "from": p.from,
1248 "to": p.to,
1249 "found": !path.is_empty(),
1250 "path": nodes,
1251 }))
1252 }
1253 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1254 }
1255 }
1256
1257 #[tool(
1259 description = "List all symbols (functions, structs, etc.) in a source file whose span \
1260 overlaps the given line range. Use this to map a stack trace, diff hunk, or grep result \
1261 to the symbols responsible."
1262 )]
1263 fn list_symbols_in_range(
1264 &self,
1265 Parameters(p): Parameters<ListSymbolsInRangeParams>,
1266 ) -> CallToolResult {
1267 let branch = p
1268 .branch
1269 .as_deref()
1270 .unwrap_or(&self.default_branch)
1271 .to_owned();
1272 let store = match self.store.lock() {
1273 Ok(g) => g,
1274 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1275 };
1276 let path = Path::new(&p.file);
1277 match store.list_symbols_in_range(&branch, path, p.start_line, p.end_line) {
1278 Ok(nodes) => {
1279 let items: Vec<_> = nodes
1280 .iter()
1281 .map(|n| {
1282 json!({
1283 "kind": n.kind.to_string(),
1284 "name": n.name,
1285 "qualified_name": n.qualified_name,
1286 "start_line": n.span.start_line,
1287 "end_line": n.span.end_line,
1288 "loc": n.metadata.loc,
1289 })
1290 })
1291 .collect();
1292 let (items, truncated) = self.budget_items(items);
1293 CallToolResult::structured(json!({
1294 "file": p.file,
1295 "range": { "start": p.start_line, "end": p.end_line },
1296 "symbols": items,
1297 "truncated": truncated,
1298 }))
1299 }
1300 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1301 }
1302 }
1303
1304 #[tool(
1306 description = "Find symbols that are never called or used as a type anywhere in the indexed \
1307 codebase. Useful for identifying dead code, safe-to-rename candidates, or refactoring targets. \
1308 Pass kind='function' to restrict to functions only."
1309 )]
1310 fn find_unused_symbols(
1311 &self,
1312 Parameters(p): Parameters<FindUnusedSymbolsParams>,
1313 ) -> CallToolResult {
1314 let branch = p
1315 .branch
1316 .as_deref()
1317 .unwrap_or(&self.default_branch)
1318 .to_owned();
1319 let kind = p.kind.as_deref().and_then(|k| match k {
1320 "function" => Some(NodeKind::Function),
1321 "method" => Some(NodeKind::Method),
1322 "struct" => Some(NodeKind::Struct),
1323 "trait" => Some(NodeKind::Trait),
1324 "interface" => Some(NodeKind::Interface),
1325 "enum" => Some(NodeKind::Enum),
1326 "constant" => Some(NodeKind::Constant),
1327 _ => None,
1328 });
1329 let store = match self.store.lock() {
1330 Ok(g) => g,
1331 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1332 };
1333 let limit = p.limit.unwrap_or(30).min(200);
1334 match store.find_unused_symbols(&branch, kind) {
1335 Ok(nodes) => {
1336 let items: Vec<_> = nodes
1340 .iter()
1341 .take(limit)
1342 .map(|n| {
1343 json!({
1344 "kind": n.kind.to_string(),
1345 "name": n.name,
1346 "qualified_name": n.qualified_name,
1347 "file": n.file.display().to_string(),
1348 "start_line": n.span.start_line,
1349 "visibility": format!("{:?}", n.metadata.visibility),
1350 })
1351 })
1352 .collect();
1353 let total = nodes.len();
1354 let (items, budget_trunc) = self.budget_items(items);
1355 CallToolResult::structured(json!({
1356 "branch": branch,
1357 "unused_symbols": items,
1358 "count": total,
1359 "returned": items.len(),
1360 "truncated": total > items.len() || budget_trunc,
1361 }))
1362 }
1363 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1364 }
1365 }
1366
1367 #[tool(
1369 description = "Return the subgraph centred on a seed symbol — nodes and edges reachable \
1370 within `depth` hops (default 1; raise for wider context). Direction='out' downstream, \
1371 'in' upstream, 'both' (default). Capped at `limit` nodes (default 30) with a `truncated` \
1372 flag — prefer find_callers/find_callees for a targeted answer over a wide neighbourhood dump."
1373 )]
1374 fn get_subgraph(&self, Parameters(p): Parameters<GetSubgraphParams>) -> CallToolResult {
1375 let branch = p
1376 .branch
1377 .as_deref()
1378 .unwrap_or(&self.default_branch)
1379 .to_owned();
1380 let depth = p.depth.unwrap_or(1).clamp(1, 5);
1381 let max_nodes = p.limit.unwrap_or(20).min(200);
1382 let direction = p.direction.as_deref().unwrap_or("both").to_owned();
1383 let store = match self.store.lock() {
1384 Ok(g) => g,
1385 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1386 };
1387 match store.get_subgraph(&branch, &p.seed_name, depth, &direction) {
1388 Ok(sg) => {
1389 let kept: Vec<_> = sg.nodes.iter().take(max_nodes).collect();
1393 let kept_ids: std::collections::HashSet<String> =
1394 kept.iter().map(|n| n.id.as_str()).collect();
1395 let name_of: std::collections::HashMap<String, &str> = kept
1398 .iter()
1399 .map(|n| (n.id.as_str(), n.name.as_str()))
1400 .collect();
1401 let nodes: Vec<_> = kept
1402 .iter()
1403 .map(|n| {
1404 json!({
1405 "kind": n.kind.to_string(),
1406 "name": n.name,
1407 "file": n.file.display().to_string(),
1408 "start_line": n.span.start_line,
1409 })
1410 })
1411 .collect();
1412 let edges: Vec<_> = sg
1413 .edges
1414 .iter()
1415 .filter(|e| {
1416 kept_ids.contains(&e.src.as_str()) && kept_ids.contains(&e.dst.as_str())
1417 })
1418 .map(|e| {
1419 json!({
1420 "from": name_of.get(&e.src.as_str()).copied().unwrap_or(""),
1421 "to": name_of.get(&e.dst.as_str()).copied().unwrap_or(""),
1422 "kind": e.kind.to_string(),
1423 "confidence": e.confidence.to_string(),
1424 })
1425 })
1426 .collect();
1427 let (nodes, n_trunc) = self.budget_items(nodes);
1429 let (edges, e_trunc) = self.budget_items(edges);
1430 CallToolResult::structured(json!({
1431 "seed": p.seed_name,
1432 "depth": depth,
1433 "direction": direction,
1434 "node_count": sg.nodes.len(),
1435 "edge_count": sg.edges.len(),
1436 "returned_nodes": nodes.len(),
1437 "returned_edges": edges.len(),
1438 "truncated": sg.nodes.len() > nodes.len() || n_trunc || e_trunc,
1439 "nodes": nodes,
1440 "edges": edges,
1441 }))
1442 }
1443 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
1444 }
1445 }
1446
1447 #[tool(
1449 description = "Markdown wiki for a symbol: signature, doc-comment, top callers/callees. \
1450 Use for deep explanation; use lookup_symbol for a quick definition."
1451 )]
1452 fn wiki_symbol(&self, Parameters(p): Parameters<WikiSymbolParams>) -> CallToolResult {
1453 let branch = p
1454 .branch
1455 .as_deref()
1456 .unwrap_or(&self.default_branch)
1457 .to_owned();
1458 let store = match self.store.lock() {
1459 Ok(g) => g,
1460 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1461 };
1462 match super::wiki::render_symbol(&*store, &branch, &p.name) {
1463 Ok(markdown) => CallToolResult::structured(json!({
1464 "symbol": p.name,
1465 "branch": branch,
1466 "markdown": markdown,
1467 })),
1468 Err(e) => CallToolResult::error(vec![Content::text(format!("wiki failed: {e}"))]),
1469 }
1470 }
1471
1472 #[tool(
1474 description = "Search the code graph by name or description. Combines token/fuzzy text \
1475 matching (CamelCase-aware, typo-tolerant) with semantic vector similarity so you can \
1476 search without knowing the exact symbol name. Ranks exact > prefix > semantic > \
1477 substring; functions/structs boosted. Default limit=10."
1478 )]
1479 fn search_code(&self, Parameters(p): Parameters<SearchCodeParams>) -> CallToolResult {
1480 let branch = p
1481 .branch
1482 .as_deref()
1483 .unwrap_or(&self.default_branch)
1484 .to_owned();
1485
1486 let text_hits = {
1488 let store = match self.store.lock() {
1489 Ok(g) => g,
1490 Err(_) => {
1491 return CallToolResult::error(vec![Content::text("store mutex poisoned")])
1492 }
1493 };
1494 match super::search::search(&*store, &branch, &p.query, p.limit) {
1495 Ok(h) => h,
1496 Err(e) => {
1497 return CallToolResult::error(vec![Content::text(format!(
1498 "search failed: {e}"
1499 ))])
1500 }
1501 }
1502 };
1503
1504 let sem_hits = if let Ok(sem) = self.semantic.try_lock() {
1507 if let SemanticState::Ready { embedder, index } = &*sem {
1508 embedder.embed_one(&p.query).ok().map(|qvec| {
1509 let limit = p.limit.unwrap_or(10).min(200);
1510 index.top_k(&qvec, limit * 2)
1511 })
1512 } else {
1513 None
1514 }
1515 } else {
1516 None
1517 };
1518
1519 let mut all_hits = text_hits;
1521 let text_names: std::collections::HashSet<String> =
1522 all_hits.iter().map(|h| h.name.clone()).collect();
1523
1524 if let Some(sem_ids) = sem_hits {
1525 if !sem_ids.is_empty() {
1526 let store = match self.store.lock() {
1527 Ok(g) => g,
1528 Err(_) => {
1529 return CallToolResult::error(vec![Content::text("store mutex poisoned")])
1530 }
1531 };
1532 if let Ok(nodes) = store.get_nodes_by_ids(&branch, &sem_ids) {
1533 for n in nodes {
1534 if !text_names.contains(&n.name) {
1535 all_hits.push(super::search::SearchHit {
1536 name: n.name,
1537 qualified_name: n.qualified_name,
1538 kind: n.kind.to_string(),
1539 file: n.file.display().to_string(),
1540 start_line: n.span.start_line,
1541 score: 45, });
1543 }
1544 }
1545 }
1546 }
1547 }
1548
1549 let limit = p.limit.unwrap_or(10).min(200);
1550 all_hits.sort_by(|a, b| {
1551 b.score
1552 .cmp(&a.score)
1553 .then_with(|| a.name.len().cmp(&b.name.len()))
1554 });
1555 all_hits.truncate(limit);
1556
1557 CallToolResult::structured(json!({
1558 "query": p.query,
1559 "branch": branch,
1560 "count": all_hits.len(),
1561 "semantic_available": matches!(
1562 self.semantic.try_lock().as_deref(),
1563 Ok(SemanticState::Ready { .. })
1564 ),
1565 "hits": all_hits,
1566 }))
1567 }
1568
1569 #[tool(
1571 description = "Generate a guided tour through the codebase. Without a seed, picks the \
1572 highest-centrality public functions/structs to give a new contributor an entry path. \
1573 With a seed, BFS-walks outward from it along call edges. Returns ordered tour steps \
1574 with rationale per step and a rendered markdown plan."
1575 )]
1576 fn start_tour(&self, Parameters(p): Parameters<StartTourParams>) -> CallToolResult {
1577 let branch = p
1578 .branch
1579 .as_deref()
1580 .unwrap_or(&self.default_branch)
1581 .to_owned();
1582 let store = match self.store.lock() {
1583 Ok(g) => g,
1584 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
1585 };
1586 match super::tour::generate(&*store, &branch, p.seed.as_deref(), p.limit) {
1587 Ok(tour) => {
1588 let markdown = super::tour::render_markdown(&tour);
1589 CallToolResult::structured(json!({
1590 "branch": tour.branch,
1591 "seed": tour.seed,
1592 "components": tour.components,
1593 "steps": tour.steps,
1594 "markdown": markdown,
1595 }))
1596 }
1597 Err(e) => CallToolResult::error(vec![Content::text(format!("tour failed: {e}"))]),
1598 }
1599 }
1600
1601 #[tool(description = "Query the GitCortex code knowledge graph. \
1606 action: lookup_symbol | find_callers | find_callees | find_unused_symbols | \
1607 get_subgraph | search_code | start_tour | wiki_symbol | trace_path | \
1608 list_definitions | symbol_context | list_symbols_in_range | graph_stats | ast_search | \
1609 type_hierarchy | find_importers | find_type_usages | module_dependencies | \
1610 get_call_sites | branch_diff_graph. \
1611 params: JSON object with the same fields as the individual tool (name/function_name/\
1612 seed_name/query/file/branch/depth/limit/direction as applicable). \
1613 Returns identical output to the individual tool.")]
1614 fn gcx(&self, Parameters(p): Parameters<GcxDispatchParams>) -> CallToolResult {
1615 let branch_val = p
1616 .params
1617 .get("branch")
1618 .and_then(|v| v.as_str())
1619 .map(|s| s.to_owned());
1620
1621 macro_rules! str_field {
1623 ($key:expr) => {
1624 match p.params.get($key).and_then(|v| v.as_str()) {
1625 Some(s) => s.to_owned(),
1626 None => {
1627 return CallToolResult::error(vec![Content::text(format!(
1628 "gcx dispatch: params.{} is required for action={}",
1629 $key, p.action
1630 ))])
1631 }
1632 }
1633 };
1634 }
1635
1636 match p.action.as_str() {
1637 "lookup_symbol" => self.lookup_symbol(Parameters(LookupSymbolParams {
1638 name: str_field!("name"),
1639 fuzzy: p.params.get("fuzzy").and_then(|v| v.as_bool()),
1640 branch: branch_val,
1641 })),
1642 "find_callers" => self.find_callers(Parameters(FindCallersParams {
1643 function_name: str_field!("function_name"),
1644 depth: p
1645 .params
1646 .get("depth")
1647 .and_then(|v| v.as_u64())
1648 .map(|n| n as u8),
1649 branch: branch_val,
1650 })),
1651 "find_callees" => self.find_callees(Parameters(FindCalleesParams {
1652 function_name: str_field!("function_name"),
1653 depth: p
1654 .params
1655 .get("depth")
1656 .and_then(|v| v.as_u64())
1657 .map(|n| n as u8),
1658 branch: branch_val,
1659 })),
1660 "find_unused_symbols" => {
1661 self.find_unused_symbols(Parameters(FindUnusedSymbolsParams {
1662 kind: p
1663 .params
1664 .get("kind")
1665 .and_then(|v| v.as_str())
1666 .map(|s| s.to_owned()),
1667 limit: p
1668 .params
1669 .get("limit")
1670 .and_then(|v| v.as_u64())
1671 .map(|n| n as usize),
1672 branch: branch_val,
1673 }))
1674 }
1675 "get_subgraph" => self.get_subgraph(Parameters(GetSubgraphParams {
1676 seed_name: str_field!("seed_name"),
1677 depth: p
1678 .params
1679 .get("depth")
1680 .and_then(|v| v.as_u64())
1681 .map(|n| n as u8),
1682 direction: p
1683 .params
1684 .get("direction")
1685 .and_then(|v| v.as_str())
1686 .map(|s| s.to_owned()),
1687 limit: p
1688 .params
1689 .get("limit")
1690 .and_then(|v| v.as_u64())
1691 .map(|n| n as usize),
1692 branch: branch_val,
1693 })),
1694 "search_code" => self.search_code(Parameters(SearchCodeParams {
1695 query: str_field!("query"),
1696 limit: p
1697 .params
1698 .get("limit")
1699 .and_then(|v| v.as_u64())
1700 .map(|n| n as usize),
1701 branch: branch_val,
1702 })),
1703 "start_tour" => self.start_tour(Parameters(StartTourParams {
1704 seed: p
1705 .params
1706 .get("seed")
1707 .and_then(|v| v.as_str())
1708 .map(|s| s.to_owned()),
1709 limit: p
1710 .params
1711 .get("limit")
1712 .and_then(|v| v.as_u64())
1713 .map(|n| n as usize),
1714 branch: branch_val,
1715 })),
1716 "wiki_symbol" => self.wiki_symbol(Parameters(WikiSymbolParams {
1717 name: str_field!("name"),
1718 branch: branch_val,
1719 })),
1720 "trace_path" => self.trace_path(Parameters(TracePathParams {
1721 from: p
1722 .params
1723 .get("from")
1724 .or_else(|| p.params.get("src"))
1725 .and_then(|v| v.as_str())
1726 .map(|s| s.to_owned())
1727 .unwrap_or_default(),
1728 to: p
1729 .params
1730 .get("to")
1731 .or_else(|| p.params.get("dst"))
1732 .and_then(|v| v.as_str())
1733 .map(|s| s.to_owned())
1734 .unwrap_or_default(),
1735 branch: branch_val,
1736 })),
1737 "list_definitions" => self.list_definitions(Parameters(ListDefinitionsParams {
1738 file: str_field!("file"),
1739 branch: branch_val,
1740 })),
1741 "symbol_context" => self.symbol_context(Parameters(SymbolContextParams {
1742 name: str_field!("name"),
1743 branch: branch_val,
1744 })),
1745 "graph_stats" => self.graph_stats(Parameters(GraphStatsParams { branch: branch_val })),
1746 "type_hierarchy" => self.type_hierarchy(Parameters(TypeHierarchyParams {
1747 name: str_field!("name"),
1748 branch: branch_val,
1749 })),
1750 "find_importers" => self.find_importers(Parameters(FindImportersParams {
1751 name: str_field!("name"),
1752 branch: branch_val,
1753 })),
1754 "get_call_sites" => self.get_call_sites(Parameters(GetCallSitesParams {
1755 name: str_field!("name"),
1756 branch: branch_val,
1757 })),
1758 "find_type_usages" => self.find_type_usages(Parameters(FindTypeUsagesParams {
1759 name: str_field!("name"),
1760 branch: branch_val,
1761 })),
1762 "module_dependencies" => {
1763 self.module_dependencies(Parameters(ModuleDependenciesParams {
1764 name: str_field!("name"),
1765 branch: branch_val,
1766 }))
1767 }
1768 "ast_search" => self.ast_search(Parameters(AstSearchParams {
1769 kind: p
1770 .params
1771 .get("kind")
1772 .and_then(|v| v.as_str())
1773 .map(|s| s.to_owned()),
1774 is_async: p.params.get("is_async").and_then(|v| v.as_bool()),
1775 visibility: p
1776 .params
1777 .get("visibility")
1778 .and_then(|v| v.as_str())
1779 .map(|s| s.to_owned()),
1780 min_complexity: p
1781 .params
1782 .get("min_complexity")
1783 .and_then(|v| v.as_u64())
1784 .map(|n| n as u32),
1785 max_complexity: p
1786 .params
1787 .get("max_complexity")
1788 .and_then(|v| v.as_u64())
1789 .map(|n| n as u32),
1790 name_contains: p
1791 .params
1792 .get("name_contains")
1793 .and_then(|v| v.as_str())
1794 .map(|s| s.to_owned()),
1795 annotation: p
1796 .params
1797 .get("annotation")
1798 .and_then(|v| v.as_str())
1799 .map(|s| s.to_owned()),
1800 limit: p
1801 .params
1802 .get("limit")
1803 .and_then(|v| v.as_u64())
1804 .map(|n| n as usize),
1805 branch: branch_val,
1806 })),
1807 "list_symbols_in_range" => {
1808 self.list_symbols_in_range(Parameters(ListSymbolsInRangeParams {
1809 file: str_field!("file"),
1810 start_line: p
1811 .params
1812 .get("start_line")
1813 .and_then(|v| v.as_u64())
1814 .unwrap_or(1) as u32,
1815 end_line: p
1816 .params
1817 .get("end_line")
1818 .and_then(|v| v.as_u64())
1819 .unwrap_or(u32::MAX as u64) as u32,
1820 branch: branch_val,
1821 }))
1822 }
1823 other => CallToolResult::error(vec![Content::text(format!(
1824 "gcx dispatch: unknown action '{other}'. Valid: lookup_symbol, find_callers, \
1825 find_callees, find_unused_symbols, get_subgraph, search_code, start_tour, \
1826 wiki_symbol, trace_path, list_definitions, symbol_context, list_symbols_in_range, \
1827 graph_stats, ast_search, type_hierarchy, find_importers, find_type_usages, \
1828 module_dependencies, get_call_sites"
1829 ))]),
1830 }
1831 }
1832}
1833
1834#[derive(Debug, Deserialize, JsonSchema)]
1837pub struct DetectImpactParams {
1838 pub changed_files: String,
1840 pub branch: Option<String>,
1842}
1843
1844#[derive(Debug, Deserialize, JsonSchema)]
1845pub struct GenerateMapParams {
1846 pub branch: Option<String>,
1848}
1849
1850#[prompt_router]
1853impl GitCortexServer {
1854 #[prompt(
1858 name = "detect_impact",
1859 description = "Pre-commit impact analysis — maps changed files to affected callers and scores risk"
1860 )]
1861 fn detect_impact(&self, Parameters(p): Parameters<DetectImpactParams>) -> GetPromptResult {
1862 let branch = p.branch.as_deref().unwrap_or("main");
1863 let files = p.changed_files.trim().to_owned();
1864
1865 let user_msg = format!(
1866 r#"I am about to commit changes to these files on branch `{branch}`:
1867
1868{files}
1869
1870Please analyse the blast radius of these changes using the GitCortex knowledge graph:
1871
18721. For each changed file call `list_definitions` to identify which symbols were likely touched.
18732. For each key function or struct, call `find_callers` to find direct callers.
18743. Repeat `find_callers` one level deeper for any HIGH-traffic callers.
18754. Summarise your findings as:
1876 - **Changed symbols**: list each modified function/struct with its file and line.
1877 - **Direct callers**: who calls the changed code.
1878 - **Transitive callers**: notable callers two hops away.
1879 - **Risk level**: LOW / MEDIUM / HIGH / CRITICAL with a one-line justification.
1880 - **Recommended actions**: tests to run, reviewers to notify, docs to update.
1881"#
1882 );
1883
1884 GetPromptResult::new(vec![PromptMessage::new_text(
1885 PromptMessageRole::User,
1886 user_msg,
1887 )])
1888 .with_description("Impact analysis of staged changes using the call graph")
1889 }
1890
1891 #[prompt(
1894 name = "generate_map",
1895 description = "Architecture documentation — produces a Mermaid diagram of modules, types, and key relationships"
1896 )]
1897 fn generate_map(&self, Parameters(p): Parameters<GenerateMapParams>) -> GetPromptResult {
1898 let branch = p.branch.as_deref().unwrap_or("main");
1899
1900 let user_msg = format!(
1901 r#"Generate an architecture map of this codebase on branch `{branch}` using GitCortex.
1902
1903Steps:
19041. Call `list_definitions` on each major source file to collect modules, structs, traits, and functions.
19052. Call `find_callers` on the top-level entry points to understand key execution flows.
19063. Call `lookup_symbol` on core traits to find all their implementors.
1907
1908Then produce:
1909
1910## Architecture Overview
1911A prose summary (3–5 sentences) of what this codebase does and how it is structured.
1912
1913## Module Map
1914```mermaid
1915graph TD
1916 %% Add nodes for each module/crate and edges for depends-on relationships
1917```
1918
1919## Key Types
1920A table: | Type | Kind | Responsibility | Implemented by |
1921
1922## Core Flows
1923Numbered list of the 2–4 most important execution paths (entry point → key functions → output).
1924
1925## Dependency Notes
1926Any circular dependencies, large fan-outs, or architectural concerns visible in the graph.
1927"#
1928 );
1929
1930 GetPromptResult::new(vec![PromptMessage::new_text(
1931 PromptMessageRole::User,
1932 user_msg,
1933 )])
1934 .with_description(
1935 "Architecture documentation with Mermaid diagram from the knowledge graph",
1936 )
1937 }
1938}
1939
1940#[tool_handler(router = self.active_tool_router())]
1943#[prompt_handler(router = Self::prompt_router())]
1944impl rmcp::ServerHandler for GitCortexServer {
1945 fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
1946 self.active_tool_router().get(name).cloned()
1947 }
1948}
1949
1950fn run_git_diff(repo_root: &Path, args: &[&str]) -> Option<String> {
1953 let out = std::process::Command::new("git")
1954 .args(args)
1955 .current_dir(repo_root)
1956 .output()
1957 .ok()?;
1958 if out.status.success() {
1959 String::from_utf8(out.stdout).ok()
1960 } else {
1961 None
1962 }
1963}
1964
1965fn parse_diff_hunks(diff: &str) -> Vec<(String, Vec<(u32, u32)>)> {
1967 let mut result: Vec<(String, Vec<(u32, u32)>)> = Vec::new();
1968 let mut cur_file: Option<String> = None;
1969 let mut cur_hunks: Vec<(u32, u32)> = Vec::new();
1970
1971 for line in diff.lines() {
1972 if let Some(path) = line.strip_prefix("+++ b/") {
1973 if let Some(f) = cur_file.take() {
1974 if !cur_hunks.is_empty() {
1975 result.push((f, std::mem::take(&mut cur_hunks)));
1976 }
1977 }
1978 cur_file = Some(path.to_owned());
1979 } else if line.starts_with("@@ ") {
1980 if let Some(hunk) = parse_hunk_header(line) {
1981 cur_hunks.push(hunk);
1982 }
1983 }
1984 }
1985 if let Some(f) = cur_file {
1986 if !cur_hunks.is_empty() {
1987 result.push((f, cur_hunks));
1988 }
1989 }
1990 result
1991}
1992
1993fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
1996 let rest = line.strip_prefix("@@ ")?;
1997 let plus_pos = rest.find(" +")?;
1998 let new_part = &rest[plus_pos + 2..];
1999 let end = new_part.find(' ').unwrap_or(new_part.len());
2000 let range = &new_part[..end];
2001 if let Some(comma) = range.find(',') {
2002 let start: u32 = range[..comma].parse().ok()?;
2003 let count: u32 = range[comma + 1..].parse().ok()?;
2004 Some((start, start + count.saturating_sub(1)))
2005 } else {
2006 let start: u32 = range.parse().ok()?;
2007 Some((start, start))
2008 }
2009}