use std::path::{Path, PathBuf};
use std::sync::Arc;
use gitcortex_core::{schema::NodeKind, store::GraphStore};
use gitcortex_store::kuzu::KuzuGraphStore;
use rmcp::{
handler::server::router::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::{
CallToolResult, Content, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
PaginatedRequestParams, PromptMessage, PromptMessageRole,
},
prompt, prompt_handler, prompt_router,
service::RequestContext,
tool, tool_handler, tool_router, RoleServer,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GcxDispatchParams {
pub action: String,
pub params: serde_json::Value,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LookupSymbolParams {
pub name: String,
pub fuzzy: Option<bool>,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindCallersParams {
pub function_name: String,
pub depth: Option<u8>,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SymbolContextParams {
pub name: String,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListDefinitionsParams {
pub file: String,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BranchDiffParams {
pub from_branch: String,
pub to_branch: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DetectChangesParams {
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindCalleesParams {
pub function_name: String,
pub depth: Option<u8>,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindImplementorsParams {
pub trait_name: String,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct TracePathParams {
pub from: String,
pub to: String,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListSymbolsInRangeParams {
pub file: String,
pub start_line: u32,
pub end_line: u32,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindUnusedSymbolsParams {
pub kind: Option<String>,
pub limit: Option<usize>,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetSubgraphParams {
pub seed_name: String,
pub depth: Option<u8>,
pub direction: Option<String>,
pub limit: Option<usize>,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WikiSymbolParams {
pub name: String,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SearchCodeParams {
pub query: String,
pub limit: Option<usize>,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct StartTourParams {
pub seed: Option<String>,
pub limit: Option<usize>,
pub branch: Option<String>,
}
#[derive(Clone)]
pub struct GitCortexServer {
store: Arc<std::sync::Mutex<KuzuGraphStore>>,
repo_root: PathBuf,
default_branch: String,
compact: bool,
}
impl GitCortexServer {
pub fn new(repo_root: &Path) -> anyhow::Result<Self> {
Self::new_with_mode(repo_root, false)
}
pub fn new_with_mode(repo_root: &Path, compact: bool) -> anyhow::Result<Self> {
let store = KuzuGraphStore::open(repo_root)?;
let default_branch = detect_current_branch(repo_root).unwrap_or_else(|| "main".into());
Ok(Self {
store: Arc::new(std::sync::Mutex::new(store)),
repo_root: repo_root.to_owned(),
default_branch,
compact,
})
}
fn active_tool_router(&self) -> ToolRouter<Self> {
let mut router = Self::tool_router();
if self.compact {
for name in [
"lookup_symbol",
"find_callers",
"symbol_context",
"list_definitions",
"branch_diff_graph",
"detect_changes",
"find_callees",
"find_implementors",
"trace_path",
"list_symbols_in_range",
"find_unused_symbols",
"get_subgraph",
"wiki_symbol",
"search_code",
"start_tour",
] {
router.disable_route(name);
}
}
router
}
}
fn detect_current_branch(repo_root: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(repo_root)
.output()
.ok()?;
if out.status.success() {
let s = String::from_utf8(out.stdout).ok()?;
let b = s.trim().to_owned();
if b.is_empty() {
None
} else {
Some(b)
}
} else {
None
}
}
#[tool_router]
impl GitCortexServer {
#[tool(
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."
)]
fn lookup_symbol(&self, Parameters(p): Parameters<LookupSymbolParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let fuzzy = p.fuzzy.unwrap_or(false);
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.lookup_symbol(&branch, &p.name, fuzzy) {
Ok(nodes) => {
let items: Vec<_> = nodes
.iter()
.map(|n| {
json!({
"id": n.id.as_str(),
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
"end_line": n.span.end_line,
"visibility": format!("{:?}", n.metadata.visibility),
"is_async": n.metadata.is_async,
"is_unsafe": n.metadata.is_unsafe,
})
})
.collect();
CallToolResult::structured(json!(items))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Find callers of a function. depth=1 (default) = direct callers; \
depth=2..5 = multi-hop. Results capped per hop; total count always returned."
)]
fn find_callers(&self, Parameters(p): Parameters<FindCallersParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let depth = p.depth.unwrap_or(1).max(1);
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
const MAX_CALLERS: usize = 25;
const MAX_PER_HOP: usize = 15;
if depth == 1 {
match store.find_callers(&branch, &p.function_name) {
Ok(nodes) => {
let total = nodes.len();
let items: Vec<_> = nodes
.iter()
.take(MAX_CALLERS)
.map(|n| {
json!({
"hop": 1,
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
let risk = match total {
0..=2 => "LOW",
3..=10 => "MEDIUM",
11..=30 => "HIGH",
_ => "CRITICAL",
};
CallToolResult::structured(json!({
"summary": format!("{total} caller(s) — risk {risk}{}",
if total > items.len() {
format!(", showing top {}", items.len())
} else { String::new() }),
"function": p.function_name,
"depth": 1,
"risk_level": risk,
"total_callers": total,
"returned": items.len(),
"truncated": total > items.len(),
"callers": items,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
} else {
match store.find_callers_deep(&branch, &p.function_name, depth) {
Ok(result) => {
let hops: Vec<_> = result
.hops
.iter()
.enumerate()
.map(|(i, nodes)| {
let total = nodes.len();
let callers: Vec<_> = nodes
.iter()
.take(MAX_PER_HOP)
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
json!({
"hop": i + 1,
"total": total,
"truncated": total > MAX_PER_HOP,
"callers": callers,
})
})
.collect();
CallToolResult::structured(json!({
"function": p.function_name,
"depth": depth,
"risk_level": result.risk_level,
"hops": hops,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
}
#[tool(
description = "Get a complete picture of a symbol in one call: where it's defined, \
what calls it (callers), what it calls (callees), and which code references it as a type. \
Use this instead of chaining lookup_symbol + find_callers separately."
)]
fn symbol_context(&self, Parameters(p): Parameters<SymbolContextParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.symbol_context(&branch, &p.name) {
Ok(ctx) => {
let node_json = |n: &gitcortex_core::graph::Node| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
};
CallToolResult::structured(json!({
"definition": {
"kind": ctx.definition.kind.to_string(),
"name": ctx.definition.name,
"qualified_name": ctx.definition.qualified_name,
"file": ctx.definition.file.display().to_string(),
"start_line": ctx.definition.span.start_line,
"end_line": ctx.definition.span.end_line,
"visibility": format!("{:?}", ctx.definition.metadata.visibility),
"is_async": ctx.definition.metadata.is_async,
},
"callers": ctx.callers.iter().map(node_json).collect::<Vec<_>>(),
"callees": ctx.callees.iter().map(node_json).collect::<Vec<_>>(),
"used_by": ctx.used_by.iter().map(node_json).collect::<Vec<_>>(),
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "List all functions, structs, traits, and other definitions in a source file, ordered by line number."
)]
fn list_definitions(&self, Parameters(p): Parameters<ListDefinitionsParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.list_definitions(&branch, Path::new(&p.file)) {
Ok(nodes) => {
let items: Vec<_> = nodes
.iter()
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"start_line": n.span.start_line,
"end_line": n.span.end_line,
"loc": n.metadata.loc,
"visibility": format!("{:?}", n.metadata.visibility),
"is_async": n.metadata.is_async,
})
})
.collect();
CallToolResult::structured(json!(items))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Show what nodes were added or removed between two branches. Useful for understanding what changed in a feature branch vs main."
)]
fn branch_diff_graph(&self, Parameters(p): Parameters<BranchDiffParams>) -> CallToolResult {
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.branch_diff(&p.from_branch, &p.to_branch) {
Ok(diff) => {
let added: Vec<_> = diff
.added_nodes
.iter()
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
let from_nodes = store.list_all_nodes(&p.from_branch).unwrap_or_default();
let from_map: std::collections::HashMap<_, _> =
from_nodes.iter().map(|n| (n.id.clone(), n)).collect();
let removed: Vec<_> = diff
.removed_node_ids
.iter()
.filter_map(|id| from_map.get(id))
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
CallToolResult::structured(json!({
"from": p.from_branch,
"to": p.to_branch,
"added_nodes": added,
"removed_nodes": removed,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Map the current git diff (staged changes, or HEAD diff if nothing is staged) \
to the indexed symbol graph. Returns which functions/structs were changed, their direct callers, \
and a risk level. Use this before committing to understand blast radius automatically."
)]
fn detect_changes(&self, Parameters(p): Parameters<DetectChangesParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let diff_text = run_git_diff(&self.repo_root, &["diff", "--staged"])
.filter(|s| !s.trim().is_empty())
.or_else(|| run_git_diff(&self.repo_root, &["diff", "HEAD"]))
.unwrap_or_default();
if diff_text.trim().is_empty() {
return CallToolResult::success(vec![Content::text(
"No staged or unstaged changes detected.",
)]);
}
let hunks = parse_diff_hunks(&diff_text);
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
let mut changed_symbols: Vec<serde_json::Value> = Vec::new();
let mut total_affected: usize = 0;
for (file_path, ranges) in &hunks {
let path = PathBuf::from(file_path);
let definitions = match store.list_definitions(&branch, &path) {
Ok(d) => d,
Err(_) => continue,
};
for node in &definitions {
let overlaps = ranges
.iter()
.any(|(s, e)| node.span.start_line <= *e && node.span.end_line >= *s);
if !overlaps {
continue;
}
let callers = store.find_callers(&branch, &node.name).unwrap_or_default();
let caller_names: Vec<&str> = callers.iter().map(|c| c.name.as_str()).collect();
total_affected += 1 + caller_names.len();
changed_symbols.push(json!({
"kind": node.kind.to_string(),
"name": node.name,
"file": file_path,
"start_line": node.span.start_line,
"end_line": node.span.end_line,
"callers": caller_names,
}));
}
}
if changed_symbols.is_empty() {
return CallToolResult::success(vec![Content::text(
"Changed lines do not overlap with any indexed symbols.",
)]);
}
let risk_level = match total_affected {
0..=5 => "LOW",
6..=20 => "MEDIUM",
21..=50 => "HIGH",
_ => "CRITICAL",
};
CallToolResult::structured(json!({
"risk_level": risk_level,
"total_affected": total_affected,
"changed_symbols": changed_symbols,
}))
}
#[tool(
description = "Find all functions/methods that the named function calls. \
Inverse of find_callers — traces forward (downstream). Use depth=1..5 to walk multiple hops. \
Returns callees grouped by hop distance."
)]
fn find_callees(&self, Parameters(p): Parameters<FindCalleesParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let depth = p.depth.unwrap_or(1).max(1);
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.find_callees(&branch, &p.function_name, depth) {
Ok(result) => {
let hops: Vec<_> = result
.hops
.iter()
.enumerate()
.map(|(i, nodes)| {
let callees: Vec<_> = nodes
.iter()
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
json!({ "hop": i + 1, "callees": callees })
})
.collect();
CallToolResult::structured(json!({
"function": p.function_name,
"depth": depth,
"hops": hops,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Find all concrete types (structs, classes) that implement or inherit the named \
trait or interface. Works for Rust traits, Java/TypeScript interfaces, and Go structural types."
)]
fn find_implementors(
&self,
Parameters(p): Parameters<FindImplementorsParams>,
) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.find_implementors(&branch, &p.trait_name) {
Ok(nodes) => {
let items: Vec<_> = nodes
.iter()
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
CallToolResult::structured(json!({
"trait": p.trait_name,
"implementors": items,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Find a call path from one function to another. Returns the shortest chain of \
calls connecting `from` to `to`. Returns an empty array if no path exists within 6 hops. \
Most useful for debugging 'how can A reach B?' questions."
)]
fn trace_path(&self, Parameters(p): Parameters<TracePathParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.trace_path(&branch, &p.from, &p.to) {
Ok(path) => {
let nodes: Vec<_> = path
.iter()
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
CallToolResult::structured(json!({
"from": p.from,
"to": p.to,
"found": !path.is_empty(),
"path": nodes,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "List all symbols (functions, structs, etc.) in a source file whose span \
overlaps the given line range. Use this to map a stack trace, diff hunk, or grep result \
to the symbols responsible."
)]
fn list_symbols_in_range(
&self,
Parameters(p): Parameters<ListSymbolsInRangeParams>,
) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
let path = Path::new(&p.file);
match store.list_symbols_in_range(&branch, path, p.start_line, p.end_line) {
Ok(nodes) => {
let items: Vec<_> = nodes
.iter()
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"start_line": n.span.start_line,
"end_line": n.span.end_line,
"loc": n.metadata.loc,
})
})
.collect();
CallToolResult::structured(json!({
"file": p.file,
"range": { "start": p.start_line, "end": p.end_line },
"symbols": items,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Find symbols that are never called or used as a type anywhere in the indexed \
codebase. Useful for identifying dead code, safe-to-rename candidates, or refactoring targets. \
Pass kind='function' to restrict to functions only."
)]
fn find_unused_symbols(
&self,
Parameters(p): Parameters<FindUnusedSymbolsParams>,
) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let kind = p.kind.as_deref().and_then(|k| match k {
"function" => Some(NodeKind::Function),
"method" => Some(NodeKind::Method),
"struct" => Some(NodeKind::Struct),
"trait" => Some(NodeKind::Trait),
"interface" => Some(NodeKind::Interface),
"enum" => Some(NodeKind::Enum),
"constant" => Some(NodeKind::Constant),
_ => None,
});
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
let limit = p.limit.unwrap_or(30).min(200);
match store.find_unused_symbols(&branch, kind) {
Ok(nodes) => {
let items: Vec<_> = nodes
.iter()
.take(limit)
.map(|n| {
json!({
"kind": n.kind.to_string(),
"name": n.name,
"qualified_name": n.qualified_name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
"visibility": format!("{:?}", n.metadata.visibility),
})
})
.collect();
CallToolResult::structured(json!({
"branch": branch,
"unused_symbols": items,
"count": nodes.len(),
"returned": items.len(),
"truncated": nodes.len() > items.len(),
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Return the subgraph centred on a seed symbol — nodes and edges reachable \
within `depth` hops (default 1; raise for wider context). Direction='out' downstream, \
'in' upstream, 'both' (default). Capped at `limit` nodes (default 30) with a `truncated` \
flag — prefer find_callers/find_callees for a targeted answer over a wide neighbourhood dump."
)]
fn get_subgraph(&self, Parameters(p): Parameters<GetSubgraphParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let depth = p.depth.unwrap_or(1).clamp(1, 5);
let max_nodes = p.limit.unwrap_or(30).min(200);
let direction = p.direction.as_deref().unwrap_or("both").to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match store.get_subgraph(&branch, &p.seed_name, depth, &direction) {
Ok(sg) => {
let kept: Vec<_> = sg.nodes.iter().take(max_nodes).collect();
let kept_ids: std::collections::HashSet<String> =
kept.iter().map(|n| n.id.as_str()).collect();
let nodes: Vec<_> = kept
.iter()
.map(|n| {
json!({
"id": n.id.as_str(),
"kind": n.kind.to_string(),
"name": n.name,
"file": n.file.display().to_string(),
"start_line": n.span.start_line,
})
})
.collect();
let edges: Vec<_> = sg
.edges
.iter()
.filter(|e| {
kept_ids.contains(&e.src.as_str()) && kept_ids.contains(&e.dst.as_str())
})
.map(|e| {
json!({
"src": e.src.as_str(),
"dst": e.dst.as_str(),
"kind": e.kind.to_string(),
})
})
.collect();
CallToolResult::structured(json!({
"seed": p.seed_name,
"depth": depth,
"direction": direction,
"node_count": sg.nodes.len(),
"edge_count": sg.edges.len(),
"returned_nodes": nodes.len(),
"returned_edges": edges.len(),
"truncated": sg.nodes.len() > nodes.len(),
"nodes": nodes,
"edges": edges,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("query failed: {e}"))]),
}
}
#[tool(
description = "Markdown wiki for a symbol: signature, doc-comment, top callers/callees. \
Use for deep explanation; use lookup_symbol for a quick definition."
)]
fn wiki_symbol(&self, Parameters(p): Parameters<WikiSymbolParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match super::wiki::render_symbol(&*store, &branch, &p.name) {
Ok(markdown) => CallToolResult::structured(json!({
"symbol": p.name,
"branch": branch,
"markdown": markdown,
})),
Err(e) => CallToolResult::error(vec![Content::text(format!("wiki failed: {e}"))]),
}
}
#[tool(
description = "Search the code graph by name. Ranks exact > prefix > substring; \
functions/structs boosted. Use before grep for symbol discovery. Default limit=10."
)]
fn search_code(&self, Parameters(p): Parameters<SearchCodeParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match super::search::search(&*store, &branch, &p.query, p.limit) {
Ok(hits) => CallToolResult::structured(json!({
"query": p.query,
"branch": branch,
"count": hits.len(),
"hits": hits,
})),
Err(e) => CallToolResult::error(vec![Content::text(format!("search failed: {e}"))]),
}
}
#[tool(
description = "Generate a guided tour through the codebase. Without a seed, picks the \
highest-centrality public functions/structs to give a new contributor an entry path. \
With a seed, BFS-walks outward from it along call edges. Returns ordered tour steps \
with rationale per step and a rendered markdown plan."
)]
fn start_tour(&self, Parameters(p): Parameters<StartTourParams>) -> CallToolResult {
let branch = p
.branch
.as_deref()
.unwrap_or(&self.default_branch)
.to_owned();
let store = match self.store.lock() {
Ok(g) => g,
Err(_) => return CallToolResult::error(vec![Content::text("store mutex poisoned")]),
};
match super::tour::generate(&*store, &branch, p.seed.as_deref(), p.limit) {
Ok(tour) => {
let markdown = super::tour::render_markdown(&tour);
CallToolResult::structured(json!({
"branch": tour.branch,
"seed": tour.seed,
"steps": tour.steps,
"markdown": markdown,
}))
}
Err(e) => CallToolResult::error(vec![Content::text(format!("tour failed: {e}"))]),
}
}
#[tool(description = "Query the GitCortex code knowledge graph. \
action: lookup_symbol | find_callers | find_callees | find_unused_symbols | \
get_subgraph | search_code | start_tour | wiki_symbol | trace_path | \
list_definitions | symbol_context | list_symbols_in_range | branch_diff_graph. \
params: JSON object with the same fields as the individual tool (name/function_name/\
seed_name/query/file/branch/depth/limit/direction as applicable). \
Returns identical output to the individual tool.")]
fn gcx(&self, Parameters(p): Parameters<GcxDispatchParams>) -> CallToolResult {
let branch_val = p
.params
.get("branch")
.and_then(|v| v.as_str())
.map(|s| s.to_owned());
macro_rules! str_field {
($key:expr) => {
match p.params.get($key).and_then(|v| v.as_str()) {
Some(s) => s.to_owned(),
None => {
return CallToolResult::error(vec![Content::text(format!(
"gcx dispatch: params.{} is required for action={}",
$key, p.action
))])
}
}
};
}
match p.action.as_str() {
"lookup_symbol" => self.lookup_symbol(Parameters(LookupSymbolParams {
name: str_field!("name"),
fuzzy: p.params.get("fuzzy").and_then(|v| v.as_bool()),
branch: branch_val,
})),
"find_callers" => self.find_callers(Parameters(FindCallersParams {
function_name: str_field!("function_name"),
depth: p
.params
.get("depth")
.and_then(|v| v.as_u64())
.map(|n| n as u8),
branch: branch_val,
})),
"find_callees" => self.find_callees(Parameters(FindCalleesParams {
function_name: str_field!("function_name"),
depth: p
.params
.get("depth")
.and_then(|v| v.as_u64())
.map(|n| n as u8),
branch: branch_val,
})),
"find_unused_symbols" => {
self.find_unused_symbols(Parameters(FindUnusedSymbolsParams {
kind: p
.params
.get("kind")
.and_then(|v| v.as_str())
.map(|s| s.to_owned()),
limit: p
.params
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize),
branch: branch_val,
}))
}
"get_subgraph" => self.get_subgraph(Parameters(GetSubgraphParams {
seed_name: str_field!("seed_name"),
depth: p
.params
.get("depth")
.and_then(|v| v.as_u64())
.map(|n| n as u8),
direction: p
.params
.get("direction")
.and_then(|v| v.as_str())
.map(|s| s.to_owned()),
limit: p
.params
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize),
branch: branch_val,
})),
"search_code" => self.search_code(Parameters(SearchCodeParams {
query: str_field!("query"),
limit: p
.params
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize),
branch: branch_val,
})),
"start_tour" => self.start_tour(Parameters(StartTourParams {
seed: p
.params
.get("seed")
.and_then(|v| v.as_str())
.map(|s| s.to_owned()),
limit: p
.params
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize),
branch: branch_val,
})),
"wiki_symbol" => self.wiki_symbol(Parameters(WikiSymbolParams {
name: str_field!("name"),
branch: branch_val,
})),
"trace_path" => self.trace_path(Parameters(TracePathParams {
from: p
.params
.get("from")
.or_else(|| p.params.get("src"))
.and_then(|v| v.as_str())
.map(|s| s.to_owned())
.unwrap_or_default(),
to: p
.params
.get("to")
.or_else(|| p.params.get("dst"))
.and_then(|v| v.as_str())
.map(|s| s.to_owned())
.unwrap_or_default(),
branch: branch_val,
})),
"list_definitions" => self.list_definitions(Parameters(ListDefinitionsParams {
file: str_field!("file"),
branch: branch_val,
})),
"symbol_context" => self.symbol_context(Parameters(SymbolContextParams {
name: str_field!("name"),
branch: branch_val,
})),
"list_symbols_in_range" => {
self.list_symbols_in_range(Parameters(ListSymbolsInRangeParams {
file: str_field!("file"),
start_line: p
.params
.get("start_line")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
end_line: p
.params
.get("end_line")
.and_then(|v| v.as_u64())
.unwrap_or(u32::MAX as u64) as u32,
branch: branch_val,
}))
}
other => CallToolResult::error(vec![Content::text(format!(
"gcx dispatch: unknown action '{other}'. Valid: lookup_symbol, find_callers, \
find_callees, find_unused_symbols, get_subgraph, search_code, start_tour, \
wiki_symbol, trace_path, list_definitions, symbol_context, list_symbols_in_range"
))]),
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DetectImpactParams {
pub changed_files: String,
pub branch: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GenerateMapParams {
pub branch: Option<String>,
}
#[prompt_router]
impl GitCortexServer {
#[prompt(
name = "detect_impact",
description = "Pre-commit impact analysis — maps changed files to affected callers and scores risk"
)]
fn detect_impact(&self, Parameters(p): Parameters<DetectImpactParams>) -> GetPromptResult {
let branch = p.branch.as_deref().unwrap_or("main");
let files = p.changed_files.trim().to_owned();
let user_msg = format!(
r#"I am about to commit changes to these files on branch `{branch}`:
{files}
Please analyse the blast radius of these changes using the GitCortex knowledge graph:
1. For each changed file call `list_definitions` to identify which symbols were likely touched.
2. For each key function or struct, call `find_callers` to find direct callers.
3. Repeat `find_callers` one level deeper for any HIGH-traffic callers.
4. Summarise your findings as:
- **Changed symbols**: list each modified function/struct with its file and line.
- **Direct callers**: who calls the changed code.
- **Transitive callers**: notable callers two hops away.
- **Risk level**: LOW / MEDIUM / HIGH / CRITICAL with a one-line justification.
- **Recommended actions**: tests to run, reviewers to notify, docs to update.
"#
);
GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
user_msg,
)])
.with_description("Impact analysis of staged changes using the call graph")
}
#[prompt(
name = "generate_map",
description = "Architecture documentation — produces a Mermaid diagram of modules, types, and key relationships"
)]
fn generate_map(&self, Parameters(p): Parameters<GenerateMapParams>) -> GetPromptResult {
let branch = p.branch.as_deref().unwrap_or("main");
let user_msg = format!(
r#"Generate an architecture map of this codebase on branch `{branch}` using GitCortex.
Steps:
1. Call `list_definitions` on each major source file to collect modules, structs, traits, and functions.
2. Call `find_callers` on the top-level entry points to understand key execution flows.
3. Call `lookup_symbol` on core traits to find all their implementors.
Then produce:
## Architecture Overview
A prose summary (3–5 sentences) of what this codebase does and how it is structured.
## Module Map
```mermaid
graph TD
%% Add nodes for each module/crate and edges for depends-on relationships
```
## Key Types
A table: | Type | Kind | Responsibility | Implemented by |
## Core Flows
Numbered list of the 2–4 most important execution paths (entry point → key functions → output).
## Dependency Notes
Any circular dependencies, large fan-outs, or architectural concerns visible in the graph.
"#
);
GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
user_msg,
)])
.with_description(
"Architecture documentation with Mermaid diagram from the knowledge graph",
)
}
}
#[tool_handler(router = self.active_tool_router())]
#[prompt_handler(router = Self::prompt_router())]
impl rmcp::ServerHandler for GitCortexServer {
fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
self.active_tool_router().get(name).cloned()
}
}
fn run_git_diff(repo_root: &Path, args: &[&str]) -> Option<String> {
let out = std::process::Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.ok()?;
if out.status.success() {
String::from_utf8(out.stdout).ok()
} else {
None
}
}
fn parse_diff_hunks(diff: &str) -> Vec<(String, Vec<(u32, u32)>)> {
let mut result: Vec<(String, Vec<(u32, u32)>)> = Vec::new();
let mut cur_file: Option<String> = None;
let mut cur_hunks: Vec<(u32, u32)> = Vec::new();
for line in diff.lines() {
if let Some(path) = line.strip_prefix("+++ b/") {
if let Some(f) = cur_file.take() {
if !cur_hunks.is_empty() {
result.push((f, std::mem::take(&mut cur_hunks)));
}
}
cur_file = Some(path.to_owned());
} else if line.starts_with("@@ ") {
if let Some(hunk) = parse_hunk_header(line) {
cur_hunks.push(hunk);
}
}
}
if let Some(f) = cur_file {
if !cur_hunks.is_empty() {
result.push((f, cur_hunks));
}
}
result
}
fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
let rest = line.strip_prefix("@@ ")?;
let plus_pos = rest.find(" +")?;
let new_part = &rest[plus_pos + 2..];
let end = new_part.find(' ').unwrap_or(new_part.len());
let range = &new_part[..end];
if let Some(comma) = range.find(',') {
let start: u32 = range[..comma].parse().ok()?;
let count: u32 = range[comma + 1..].parse().ok()?;
Some((start, start + count.saturating_sub(1)))
} else {
let start: u32 = range.parse().ok()?;
Some((start, start))
}
}