1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use gitcortex_core::{schema::NodeKind, store::GraphStore};
5use gitcortex_store::kuzu::KuzuGraphStore;
6use rmcp::{
7 handler::server::wrapper::Parameters,
8 model::{
9 CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
10 PaginatedRequestParams, PromptMessage, PromptMessageRole,
11 },
12 prompt, prompt_handler, prompt_router,
13 service::RequestContext,
14 tool, tool_handler, tool_router, RoleServer,
15};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use serde_json::json;
19
20#[derive(Debug, Deserialize, JsonSchema)]
23pub struct LookupSymbolParams {
24 pub name: String,
26 pub fuzzy: Option<bool>,
29 pub branch: Option<String>,
31}
32
33#[derive(Debug, Deserialize, JsonSchema)]
34pub struct FindCallersParams {
35 pub function_name: String,
37 pub depth: Option<u8>,
40 pub branch: Option<String>,
41}
42
43#[derive(Debug, Deserialize, JsonSchema)]
44pub struct SymbolContextParams {
45 pub name: String,
47 pub branch: Option<String>,
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
52pub struct ListDefinitionsParams {
53 pub file: String,
55 pub branch: Option<String>,
56}
57
58#[derive(Debug, Deserialize, JsonSchema)]
59pub struct BranchDiffParams {
60 pub from_branch: String,
61 pub to_branch: String,
62}
63
64#[derive(Debug, Deserialize, JsonSchema)]
65pub struct DetectChangesParams {
66 pub branch: Option<String>,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct FindCalleesParams {
72 pub function_name: String,
74 pub depth: Option<u8>,
76 pub branch: Option<String>,
77}
78
79#[derive(Debug, Deserialize, JsonSchema)]
80pub struct FindImplementorsParams {
81 pub trait_name: String,
83 pub branch: Option<String>,
84}
85
86#[derive(Debug, Deserialize, JsonSchema)]
87pub struct TracePathParams {
88 pub from: String,
90 pub to: String,
92 pub branch: Option<String>,
93}
94
95#[derive(Debug, Deserialize, JsonSchema)]
96pub struct ListSymbolsInRangeParams {
97 pub file: String,
99 pub start_line: u32,
101 pub end_line: u32,
103 pub branch: Option<String>,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct FindUnusedSymbolsParams {
108 pub kind: Option<String>,
110 pub branch: Option<String>,
111}
112
113#[derive(Debug, Deserialize, JsonSchema)]
114pub struct GetSubgraphParams {
115 pub seed_name: String,
117 pub depth: Option<u8>,
119 pub direction: Option<String>,
121 pub branch: Option<String>,
122}
123
124#[derive(Debug, Deserialize, JsonSchema)]
125pub struct WikiSymbolParams {
126 pub name: String,
128 pub branch: Option<String>,
129}
130
131#[derive(Debug, Deserialize, JsonSchema)]
132pub struct SearchCodeParams {
133 pub query: String,
135 pub limit: Option<usize>,
137 pub branch: Option<String>,
138}
139
140#[derive(Debug, Deserialize, JsonSchema)]
141pub struct StartTourParams {
142 pub seed: Option<String>,
146 pub limit: Option<usize>,
148 pub branch: Option<String>,
149}
150
151#[derive(Clone)]
156pub struct GitCortexServer {
157 store: Arc<std::sync::Mutex<KuzuGraphStore>>,
158 repo_root: PathBuf,
159 default_branch: String,
160}
161
162impl GitCortexServer {
163 pub fn new(repo_root: &Path) -> anyhow::Result<Self> {
164 let store = KuzuGraphStore::open(repo_root)?;
165 let default_branch = detect_current_branch(repo_root).unwrap_or_else(|| "main".into());
166 Ok(Self {
167 store: Arc::new(std::sync::Mutex::new(store)),
168 repo_root: repo_root.to_owned(),
169 default_branch,
170 })
171 }
172}
173
174fn detect_current_branch(repo_root: &Path) -> Option<String> {
175 let out = std::process::Command::new("git")
176 .args(["symbolic-ref", "--short", "HEAD"])
177 .current_dir(repo_root)
178 .output()
179 .ok()?;
180 if out.status.success() {
181 let s = String::from_utf8(out.stdout).ok()?;
182 let b = s.trim().to_owned();
183 if b.is_empty() {
184 None
185 } else {
186 Some(b)
187 }
188 } else {
189 None
190 }
191}
192
193#[tool_router]
196impl GitCortexServer {
197 #[tool(
199 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."
200 )]
201 fn lookup_symbol(&self, Parameters(p): Parameters<LookupSymbolParams>) -> CallToolResult {
202 let branch = p
203 .branch
204 .as_deref()
205 .unwrap_or(&self.default_branch)
206 .to_owned();
207 let fuzzy = p.fuzzy.unwrap_or(false);
208 let store = match self.store.lock() {
209 Ok(g) => g,
210 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
211 };
212 match store.lookup_symbol(&branch, &p.name, fuzzy) {
213 Ok(nodes) => {
214 let items: Vec<_> = nodes
215 .iter()
216 .map(|n| {
217 json!({
218 "id": n.id.as_str(),
219 "kind": n.kind.to_string(),
220 "name": n.name,
221 "qualified_name": n.qualified_name,
222 "file": n.file.display().to_string(),
223 "start_line": n.span.start_line,
224 "end_line": n.span.end_line,
225 "visibility": format!("{:?}", n.metadata.visibility),
226 "is_async": n.metadata.is_async,
227 "is_unsafe": n.metadata.is_unsafe,
228 })
229 })
230 .collect();
231 CallToolResult::structured(json!(items))
232 }
233 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
234 }
235 }
236
237 #[tool(
239 description = "Find all functions/methods that call the named function. \
240 Use depth=1 (default) for direct callers only, or depth=2..5 to walk the call graph \
241 multiple hops. Returns callers grouped by hop distance with a risk level."
242 )]
243 fn find_callers(&self, Parameters(p): Parameters<FindCallersParams>) -> CallToolResult {
244 let branch = p
245 .branch
246 .as_deref()
247 .unwrap_or(&self.default_branch)
248 .to_owned();
249 let depth = p.depth.unwrap_or(1).max(1);
250 let store = match self.store.lock() {
251 Ok(g) => g,
252 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
253 };
254
255 if depth == 1 {
256 match store.find_callers(&branch, &p.function_name) {
257 Ok(nodes) => {
258 let items: Vec<_> = nodes
259 .iter()
260 .map(|n| {
261 json!({
262 "hop": 1,
263 "kind": n.kind.to_string(),
264 "name": n.name,
265 "qualified_name": n.qualified_name,
266 "file": n.file.display().to_string(),
267 "start_line": n.span.start_line,
268 })
269 })
270 .collect();
271 CallToolResult::structured(json!({
272 "function": p.function_name,
273 "depth": 1,
274 "risk_level": match items.len() {
275 0..=2 => "LOW",
276 3..=10 => "MEDIUM",
277 11..=30 => "HIGH",
278 _ => "CRITICAL",
279 },
280 "callers": items,
281 }))
282 }
283 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
284 }
285 } else {
286 match store.find_callers_deep(&branch, &p.function_name, depth) {
287 Ok(result) => {
288 let hops: Vec<_> = result
289 .hops
290 .iter()
291 .enumerate()
292 .map(|(i, nodes)| {
293 let callers: Vec<_> = nodes
294 .iter()
295 .map(|n| {
296 json!({
297 "kind": n.kind.to_string(),
298 "name": n.name,
299 "qualified_name": n.qualified_name,
300 "file": n.file.display().to_string(),
301 "start_line": n.span.start_line,
302 })
303 })
304 .collect();
305 json!({ "hop": i + 1, "callers": callers })
306 })
307 .collect();
308 CallToolResult::structured(json!({
309 "function": p.function_name,
310 "depth": depth,
311 "risk_level": result.risk_level,
312 "hops": hops,
313 }))
314 }
315 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
316 }
317 }
318 }
319
320 #[tool(
322 description = "Get a complete picture of a symbol in one call: where it's defined, \
323 what calls it (callers), what it calls (callees), and which code references it as a type. \
324 Use this instead of chaining lookup_symbol + find_callers separately."
325 )]
326 fn symbol_context(&self, Parameters(p): Parameters<SymbolContextParams>) -> CallToolResult {
327 let branch = p
328 .branch
329 .as_deref()
330 .unwrap_or(&self.default_branch)
331 .to_owned();
332 let store = match self.store.lock() {
333 Ok(g) => g,
334 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
335 };
336 match store.symbol_context(&branch, &p.name) {
337 Ok(ctx) => {
338 let node_json = |n: &gitcortex_core::graph::Node| {
339 json!({
340 "kind": n.kind.to_string(),
341 "name": n.name,
342 "qualified_name": n.qualified_name,
343 "file": n.file.display().to_string(),
344 "start_line": n.span.start_line,
345 })
346 };
347 CallToolResult::structured(json!({
348 "definition": {
349 "kind": ctx.definition.kind.to_string(),
350 "name": ctx.definition.name,
351 "qualified_name": ctx.definition.qualified_name,
352 "file": ctx.definition.file.display().to_string(),
353 "start_line": ctx.definition.span.start_line,
354 "end_line": ctx.definition.span.end_line,
355 "visibility": format!("{:?}", ctx.definition.metadata.visibility),
356 "is_async": ctx.definition.metadata.is_async,
357 },
358 "callers": ctx.callers.iter().map(node_json).collect::<Vec<_>>(),
359 "callees": ctx.callees.iter().map(node_json).collect::<Vec<_>>(),
360 "used_by": ctx.used_by.iter().map(node_json).collect::<Vec<_>>(),
361 }))
362 }
363 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
364 }
365 }
366
367 #[tool(
369 description = "List all functions, structs, traits, and other definitions in a source file, ordered by line number."
370 )]
371 fn list_definitions(&self, Parameters(p): Parameters<ListDefinitionsParams>) -> CallToolResult {
372 let branch = p
373 .branch
374 .as_deref()
375 .unwrap_or(&self.default_branch)
376 .to_owned();
377 let store = match self.store.lock() {
378 Ok(g) => g,
379 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
380 };
381 match store.list_definitions(&branch, Path::new(&p.file)) {
382 Ok(nodes) => {
383 let items: Vec<_> = nodes
384 .iter()
385 .map(|n| {
386 json!({
387 "kind": n.kind.to_string(),
388 "name": n.name,
389 "qualified_name": n.qualified_name,
390 "start_line": n.span.start_line,
391 "end_line": n.span.end_line,
392 "loc": n.metadata.loc,
393 "visibility": format!("{:?}", n.metadata.visibility),
394 "is_async": n.metadata.is_async,
395 })
396 })
397 .collect();
398 CallToolResult::structured(json!(items))
399 }
400 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
401 }
402 }
403
404 #[tool(
406 description = "Show what nodes were added or removed between two branches. Useful for understanding what changed in a feature branch vs main."
407 )]
408 fn branch_diff_graph(&self, Parameters(p): Parameters<BranchDiffParams>) -> CallToolResult {
409 let store = match self.store.lock() {
410 Ok(g) => g,
411 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
412 };
413 match store.branch_diff(&p.from_branch, &p.to_branch) {
414 Ok(diff) => {
415 let added: Vec<_> = diff
416 .added_nodes
417 .iter()
418 .map(|n| {
419 json!({
420 "kind": n.kind.to_string(),
421 "name": n.name,
422 "file": n.file.display().to_string(),
423 "start_line": n.span.start_line,
424 })
425 })
426 .collect();
427
428 let from_nodes = store.list_all_nodes(&p.from_branch).unwrap_or_default();
430 let from_map: std::collections::HashMap<_, _> =
431 from_nodes.iter().map(|n| (n.id.clone(), n)).collect();
432 let removed: Vec<_> = diff
433 .removed_node_ids
434 .iter()
435 .filter_map(|id| from_map.get(id))
436 .map(|n| {
437 json!({
438 "kind": n.kind.to_string(),
439 "name": n.name,
440 "file": n.file.display().to_string(),
441 "start_line": n.span.start_line,
442 })
443 })
444 .collect();
445
446 CallToolResult::structured(json!({
447 "from": p.from_branch,
448 "to": p.to_branch,
449 "added_nodes": added,
450 "removed_nodes": removed,
451 }))
452 }
453 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
454 }
455 }
456
457 #[tool(
459 description = "Map the current git diff (staged changes, or HEAD diff if nothing is staged) \
460 to the indexed symbol graph. Returns which functions/structs were changed, their direct callers, \
461 and a risk level. Use this before committing to understand blast radius automatically."
462 )]
463 fn detect_changes(&self, Parameters(p): Parameters<DetectChangesParams>) -> CallToolResult {
464 let branch = p
465 .branch
466 .as_deref()
467 .unwrap_or(&self.default_branch)
468 .to_owned();
469
470 let diff_text = run_git_diff(&self.repo_root, &["diff", "--staged"])
471 .filter(|s| !s.trim().is_empty())
472 .or_else(|| run_git_diff(&self.repo_root, &["diff", "HEAD"]))
473 .unwrap_or_default();
474
475 if diff_text.trim().is_empty() {
476 return CallToolResult::success(vec![Content::text(
477 "No staged or unstaged changes detected.",
478 )]);
479 }
480
481 let hunks = parse_diff_hunks(&diff_text);
482 let store = match self.store.lock() {
483 Ok(g) => g,
484 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
485 };
486
487 let mut changed_symbols: Vec<serde_json::Value> = Vec::new();
488 let mut total_affected: usize = 0;
489
490 for (file_path, ranges) in &hunks {
491 let path = PathBuf::from(file_path);
492 let definitions = match store.list_definitions(&branch, &path) {
493 Ok(d) => d,
494 Err(_) => continue,
495 };
496 for node in &definitions {
497 let overlaps = ranges
498 .iter()
499 .any(|(s, e)| node.span.start_line <= *e && node.span.end_line >= *s);
500 if !overlaps {
501 continue;
502 }
503 let callers = store.find_callers(&branch, &node.name).unwrap_or_default();
504 let caller_names: Vec<&str> = callers.iter().map(|c| c.name.as_str()).collect();
505 total_affected += 1 + caller_names.len();
506 changed_symbols.push(json!({
507 "kind": node.kind.to_string(),
508 "name": node.name,
509 "file": file_path,
510 "start_line": node.span.start_line,
511 "end_line": node.span.end_line,
512 "callers": caller_names,
513 }));
514 }
515 }
516
517 if changed_symbols.is_empty() {
518 return CallToolResult::success(vec![Content::text(
519 "Changed lines do not overlap with any indexed symbols.",
520 )]);
521 }
522
523 let risk_level = match total_affected {
524 0..=5 => "LOW",
525 6..=20 => "MEDIUM",
526 21..=50 => "HIGH",
527 _ => "CRITICAL",
528 };
529
530 CallToolResult::structured(json!({
531 "risk_level": risk_level,
532 "total_affected": total_affected,
533 "changed_symbols": changed_symbols,
534 }))
535 }
536
537 #[tool(
539 description = "Find all functions/methods that the named function calls. \
540 Inverse of find_callers — traces forward (downstream). Use depth=1..5 to walk multiple hops. \
541 Returns callees grouped by hop distance."
542 )]
543 fn find_callees(&self, Parameters(p): Parameters<FindCalleesParams>) -> CallToolResult {
544 let branch = p
545 .branch
546 .as_deref()
547 .unwrap_or(&self.default_branch)
548 .to_owned();
549 let depth = p.depth.unwrap_or(1).max(1);
550 let store = match self.store.lock() {
551 Ok(g) => g,
552 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
553 };
554 match store.find_callees(&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 callees: Vec<_> = nodes
562 .iter()
563 .map(|n| {
564 json!({
565 "kind": n.kind.to_string(),
566 "name": n.name,
567 "qualified_name": n.qualified_name,
568 "file": n.file.display().to_string(),
569 "start_line": n.span.start_line,
570 })
571 })
572 .collect();
573 json!({ "hop": i + 1, "callees": callees })
574 })
575 .collect();
576 CallToolResult::structured(json!({
577 "function": p.function_name,
578 "depth": depth,
579 "hops": hops,
580 }))
581 }
582 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
583 }
584 }
585
586 #[tool(
588 description = "Find all concrete types (structs, classes) that implement or inherit the named \
589 trait or interface. Works for Rust traits, Java/TypeScript interfaces, and Go structural types."
590 )]
591 fn find_implementors(
592 &self,
593 Parameters(p): Parameters<FindImplementorsParams>,
594 ) -> CallToolResult {
595 let branch = p
596 .branch
597 .as_deref()
598 .unwrap_or(&self.default_branch)
599 .to_owned();
600 let store = match self.store.lock() {
601 Ok(g) => g,
602 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
603 };
604 match store.find_implementors(&branch, &p.trait_name) {
605 Ok(nodes) => {
606 let items: Vec<_> = nodes
607 .iter()
608 .map(|n| {
609 json!({
610 "kind": n.kind.to_string(),
611 "name": n.name,
612 "qualified_name": n.qualified_name,
613 "file": n.file.display().to_string(),
614 "start_line": n.span.start_line,
615 })
616 })
617 .collect();
618 CallToolResult::structured(json!({
619 "trait": p.trait_name,
620 "implementors": items,
621 }))
622 }
623 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
624 }
625 }
626
627 #[tool(
629 description = "Find a call path from one function to another. Returns the shortest chain of \
630 calls connecting `from` to `to`. Returns an empty array if no path exists within 6 hops. \
631 Most useful for debugging 'how can A reach B?' questions."
632 )]
633 fn trace_path(&self, Parameters(p): Parameters<TracePathParams>) -> CallToolResult {
634 let branch = p
635 .branch
636 .as_deref()
637 .unwrap_or(&self.default_branch)
638 .to_owned();
639 let store = match self.store.lock() {
640 Ok(g) => g,
641 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
642 };
643 match store.trace_path(&branch, &p.from, &p.to) {
644 Ok(path) => {
645 let nodes: Vec<_> = path
646 .iter()
647 .map(|n| {
648 json!({
649 "kind": n.kind.to_string(),
650 "name": n.name,
651 "file": n.file.display().to_string(),
652 "start_line": n.span.start_line,
653 })
654 })
655 .collect();
656 CallToolResult::structured(json!({
657 "from": p.from,
658 "to": p.to,
659 "found": !path.is_empty(),
660 "path": nodes,
661 }))
662 }
663 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
664 }
665 }
666
667 #[tool(
669 description = "List all symbols (functions, structs, etc.) in a source file whose span \
670 overlaps the given line range. Use this to map a stack trace, diff hunk, or grep result \
671 to the symbols responsible."
672 )]
673 fn list_symbols_in_range(
674 &self,
675 Parameters(p): Parameters<ListSymbolsInRangeParams>,
676 ) -> CallToolResult {
677 let branch = p
678 .branch
679 .as_deref()
680 .unwrap_or(&self.default_branch)
681 .to_owned();
682 let store = match self.store.lock() {
683 Ok(g) => g,
684 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
685 };
686 let path = Path::new(&p.file);
687 match store.list_symbols_in_range(&branch, path, p.start_line, p.end_line) {
688 Ok(nodes) => {
689 let items: Vec<_> = nodes
690 .iter()
691 .map(|n| {
692 json!({
693 "kind": n.kind.to_string(),
694 "name": n.name,
695 "qualified_name": n.qualified_name,
696 "start_line": n.span.start_line,
697 "end_line": n.span.end_line,
698 "loc": n.metadata.loc,
699 })
700 })
701 .collect();
702 CallToolResult::structured(json!({
703 "file": p.file,
704 "range": { "start": p.start_line, "end": p.end_line },
705 "symbols": items,
706 }))
707 }
708 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
709 }
710 }
711
712 #[tool(
714 description = "Find symbols that are never called or used as a type anywhere in the indexed \
715 codebase. Useful for identifying dead code, safe-to-rename candidates, or refactoring targets. \
716 Pass kind='function' to restrict to functions only."
717 )]
718 fn find_unused_symbols(
719 &self,
720 Parameters(p): Parameters<FindUnusedSymbolsParams>,
721 ) -> CallToolResult {
722 let branch = p
723 .branch
724 .as_deref()
725 .unwrap_or(&self.default_branch)
726 .to_owned();
727 let kind = p.kind.as_deref().and_then(|k| match k {
728 "function" => Some(NodeKind::Function),
729 "method" => Some(NodeKind::Method),
730 "struct" => Some(NodeKind::Struct),
731 "trait" => Some(NodeKind::Trait),
732 "interface" => Some(NodeKind::Interface),
733 "enum" => Some(NodeKind::Enum),
734 "constant" => Some(NodeKind::Constant),
735 _ => None,
736 });
737 let store = match self.store.lock() {
738 Ok(g) => g,
739 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
740 };
741 match store.find_unused_symbols(&branch, kind) {
742 Ok(nodes) => {
743 let items: Vec<_> = nodes
744 .iter()
745 .map(|n| {
746 json!({
747 "kind": n.kind.to_string(),
748 "name": n.name,
749 "qualified_name": n.qualified_name,
750 "file": n.file.display().to_string(),
751 "start_line": n.span.start_line,
752 "visibility": format!("{:?}", n.metadata.visibility),
753 })
754 })
755 .collect();
756 CallToolResult::structured(json!({
757 "branch": branch,
758 "unused_symbols": items,
759 "count": nodes.len(),
760 }))
761 }
762 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
763 }
764 }
765
766 #[tool(
768 description = "Return the subgraph centred on a seed symbol — all nodes and edges reachable \
769 within `depth` hops. Use direction='out' for downstream only, 'in' for upstream only, \
770 or 'both' (default) for both directions. Ideal for architecture rendering and impact analysis."
771 )]
772 fn get_subgraph(&self, Parameters(p): Parameters<GetSubgraphParams>) -> CallToolResult {
773 let branch = p
774 .branch
775 .as_deref()
776 .unwrap_or(&self.default_branch)
777 .to_owned();
778 let depth = p.depth.unwrap_or(2);
779 let direction = p.direction.as_deref().unwrap_or("both").to_owned();
780 let store = match self.store.lock() {
781 Ok(g) => g,
782 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
783 };
784 match store.get_subgraph(&branch, &p.seed_name, depth, &direction) {
785 Ok(sg) => {
786 let nodes: Vec<_> = sg
787 .nodes
788 .iter()
789 .map(|n| {
790 json!({
791 "id": n.id.as_str(),
792 "kind": n.kind.to_string(),
793 "name": n.name,
794 "file": n.file.display().to_string(),
795 "start_line": n.span.start_line,
796 })
797 })
798 .collect();
799 let edges: Vec<_> = sg
800 .edges
801 .iter()
802 .map(|e| {
803 json!({
804 "src": e.src.as_str(),
805 "dst": e.dst.as_str(),
806 "kind": e.kind.to_string(),
807 })
808 })
809 .collect();
810 CallToolResult::structured(json!({
811 "seed": p.seed_name,
812 "depth": depth,
813 "direction": direction,
814 "node_count": sg.nodes.len(),
815 "edge_count": sg.edges.len(),
816 "nodes": nodes,
817 "edges": edges,
818 }))
819 }
820 Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
821 }
822 }
823
824 #[tool(
826 description = "Render a wiki page for a symbol — definition signature, doc-comment, callers, \
827 callees, and used-by, formatted as markdown. Combines `lookup_symbol`, `find_callers`, and \
828 `find_callees` in one structured view ready to paste into a README or PR description."
829 )]
830 fn wiki_symbol(&self, Parameters(p): Parameters<WikiSymbolParams>) -> CallToolResult {
831 let branch = p
832 .branch
833 .as_deref()
834 .unwrap_or(&self.default_branch)
835 .to_owned();
836 let store = match self.store.lock() {
837 Ok(g) => g,
838 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
839 };
840 match super::wiki::render_symbol(&*store, &branch, &p.name) {
841 Ok(markdown) => CallToolResult::structured(json!({
842 "symbol": p.name,
843 "branch": branch,
844 "markdown": markdown,
845 })),
846 Err(e) => CallToolResult::error(vec![Content::text(format!("wiki failed: {e}"))]),
847 }
848 }
849
850 #[tool(
852 description = "Fuzzy search the code knowledge graph. Matches the query against both the \
853 unqualified `name` and full `qualified_name`, ranks by exactness (exact > prefix > \
854 substring), and applies kind boosts (functions/structs rank above generic nodes). \
855 Returns up to `limit` hits with scores."
856 )]
857 fn search_code(&self, Parameters(p): Parameters<SearchCodeParams>) -> CallToolResult {
858 let branch = p
859 .branch
860 .as_deref()
861 .unwrap_or(&self.default_branch)
862 .to_owned();
863 let store = match self.store.lock() {
864 Ok(g) => g,
865 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
866 };
867 match super::search::search(&*store, &branch, &p.query, p.limit) {
868 Ok(hits) => CallToolResult::structured(json!({
869 "query": p.query,
870 "branch": branch,
871 "count": hits.len(),
872 "hits": hits,
873 })),
874 Err(e) => CallToolResult::error(vec![Content::text(format!("search failed: {e}"))]),
875 }
876 }
877
878 #[tool(
880 description = "Generate a guided tour through the codebase. Without a seed, picks the \
881 highest-centrality public functions/structs to give a new contributor an entry path. \
882 With a seed, BFS-walks outward from it along call edges. Returns ordered tour steps \
883 with rationale per step and a rendered markdown plan."
884 )]
885 fn start_tour(&self, Parameters(p): Parameters<StartTourParams>) -> CallToolResult {
886 let branch = p
887 .branch
888 .as_deref()
889 .unwrap_or(&self.default_branch)
890 .to_owned();
891 let store = match self.store.lock() {
892 Ok(g) => g,
893 Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
894 };
895 match super::tour::generate(&*store, &branch, p.seed.as_deref(), p.limit) {
896 Ok(tour) => {
897 let markdown = super::tour::render_markdown(&tour);
898 CallToolResult::structured(json!({
899 "branch": tour.branch,
900 "seed": tour.seed,
901 "steps": tour.steps,
902 "markdown": markdown,
903 }))
904 }
905 Err(e) => CallToolResult::error(vec![Content::text(format!("tour failed: {e}"))]),
906 }
907 }
908}
909
910#[derive(Debug, Deserialize, JsonSchema)]
913pub struct DetectImpactParams {
914 pub changed_files: String,
916 pub branch: Option<String>,
918}
919
920#[derive(Debug, Deserialize, JsonSchema)]
921pub struct GenerateMapParams {
922 pub branch: Option<String>,
924}
925
926#[prompt_router]
929impl GitCortexServer {
930 #[prompt(
934 name = "detect_impact",
935 description = "Pre-commit impact analysis — maps changed files to affected callers and scores risk"
936 )]
937 fn detect_impact(&self, Parameters(p): Parameters<DetectImpactParams>) -> GetPromptResult {
938 let branch = p.branch.as_deref().unwrap_or("main");
939 let files = p.changed_files.trim().to_owned();
940
941 let user_msg = format!(
942 r#"I am about to commit changes to these files on branch `{branch}`:
943
944{files}
945
946Please analyse the blast radius of these changes using the GitCortex knowledge graph:
947
9481. For each changed file call `list_definitions` to identify which symbols were likely touched.
9492. For each key function or struct, call `find_callers` to find direct callers.
9503. Repeat `find_callers` one level deeper for any HIGH-traffic callers.
9514. Summarise your findings as:
952 - **Changed symbols**: list each modified function/struct with its file and line.
953 - **Direct callers**: who calls the changed code.
954 - **Transitive callers**: notable callers two hops away.
955 - **Risk level**: LOW / MEDIUM / HIGH / CRITICAL with a one-line justification.
956 - **Recommended actions**: tests to run, reviewers to notify, docs to update.
957"#
958 );
959
960 GetPromptResult::new(vec![PromptMessage::new_text(
961 PromptMessageRole::User,
962 user_msg,
963 )])
964 .with_description("Impact analysis of staged changes using the call graph")
965 }
966
967 #[prompt(
970 name = "generate_map",
971 description = "Architecture documentation — produces a Mermaid diagram of modules, types, and key relationships"
972 )]
973 fn generate_map(&self, Parameters(p): Parameters<GenerateMapParams>) -> GetPromptResult {
974 let branch = p.branch.as_deref().unwrap_or("main");
975
976 let user_msg = format!(
977 r#"Generate an architecture map of this codebase on branch `{branch}` using GitCortex.
978
979Steps:
9801. Call `list_definitions` on each major source file to collect modules, structs, traits, and functions.
9812. Call `find_callers` on the top-level entry points to understand key execution flows.
9823. Call `lookup_symbol` on core traits to find all their implementors.
983
984Then produce:
985
986## Architecture Overview
987A prose summary (3–5 sentences) of what this codebase does and how it is structured.
988
989## Module Map
990```mermaid
991graph TD
992 %% Add nodes for each module/crate and edges for depends-on relationships
993```
994
995## Key Types
996A table: | Type | Kind | Responsibility | Implemented by |
997
998## Core Flows
999Numbered list of the 2–4 most important execution paths (entry point → key functions → output).
1000
1001## Dependency Notes
1002Any circular dependencies, large fan-outs, or architectural concerns visible in the graph.
1003"#
1004 );
1005
1006 GetPromptResult::new(vec![PromptMessage::new_text(
1007 PromptMessageRole::User,
1008 user_msg,
1009 )])
1010 .with_description(
1011 "Architecture documentation with Mermaid diagram from the knowledge graph",
1012 )
1013 }
1014}
1015
1016#[tool_handler]
1019#[prompt_handler(router = Self::prompt_router())]
1020impl rmcp::ServerHandler for GitCortexServer {}
1021
1022fn run_git_diff(repo_root: &Path, args: &[&str]) -> Option<String> {
1025 let out = std::process::Command::new("git")
1026 .args(args)
1027 .current_dir(repo_root)
1028 .output()
1029 .ok()?;
1030 if out.status.success() {
1031 String::from_utf8(out.stdout).ok()
1032 } else {
1033 None
1034 }
1035}
1036
1037fn parse_diff_hunks(diff: &str) -> Vec<(String, Vec<(u32, u32)>)> {
1039 let mut result: Vec<(String, Vec<(u32, u32)>)> = Vec::new();
1040 let mut cur_file: Option<String> = None;
1041 let mut cur_hunks: Vec<(u32, u32)> = Vec::new();
1042
1043 for line in diff.lines() {
1044 if let Some(path) = line.strip_prefix("+++ b/") {
1045 if let Some(f) = cur_file.take() {
1046 if !cur_hunks.is_empty() {
1047 result.push((f, std::mem::take(&mut cur_hunks)));
1048 }
1049 }
1050 cur_file = Some(path.to_owned());
1051 } else if line.starts_with("@@ ") {
1052 if let Some(hunk) = parse_hunk_header(line) {
1053 cur_hunks.push(hunk);
1054 }
1055 }
1056 }
1057 if let Some(f) = cur_file {
1058 if !cur_hunks.is_empty() {
1059 result.push((f, cur_hunks));
1060 }
1061 }
1062 result
1063}
1064
1065fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
1068 let rest = line.strip_prefix("@@ ")?;
1069 let plus_pos = rest.find(" +")?;
1070 let new_part = &rest[plus_pos + 2..];
1071 let end = new_part.find(' ').unwrap_or(new_part.len());
1072 let range = &new_part[..end];
1073 if let Some(comma) = range.find(',') {
1074 let start: u32 = range[..comma].parse().ok()?;
1075 let count: u32 = range[comma + 1..].parse().ok()?;
1076 Some((start, start + count.saturating_sub(1)))
1077 } else {
1078 let start: u32 = range.parse().ok()?;
1079 Some((start, start))
1080 }
1081}