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