Skip to content

Commit 55096f0

Browse files
committed
feat(mcp): add codegraph_index_health diagnostics tool
Agents had no cheap way to check whether the current index is stale or empty before trusting a "no results" answer, or to see what other projects share the same on-disk graph database. Reuses existing infrastructure (the _registry:<slug> entries persist_graph already writes, graph_db_generation(), GitExecutor::head_commit(), IndexState::all_hashes()) rather than adding new storage. Returns current node/edge counts, generation, last-indexed time, a short git HEAD hint, a cheap re-hash-based staleness check against already-tracked files (no directory walk — that's what a real reindex is for), the other namespaces sharing this machine's graph.db, and suggested_next_queries when something looks off. Also surfaces codegraph_index_health as a suggested next query on a degraded codegraph_reindex_workspace response.
1 parent 63f38b0 commit 55096f0

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

crates/codegraph-server/src/mcp/server.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3478,14 +3478,109 @@ impl McpServer {
34783478
)
34793479
};
34803480

3481-
Ok(serde_json::json!({
3481+
let mut response = serde_json::json!({
34823482
"status": status,
34833483
"message": message,
34843484
"files_indexed": total,
34853485
"files_parsed": parsed,
34863486
"files_skipped": total - parsed,
34873487
"node_count_after": node_count_after,
34883488
"auto_retried_with_force": auto_retried
3489+
});
3490+
if degraded {
3491+
if let Some(obj) = response.as_object_mut() {
3492+
obj.insert(
3493+
"suggested_next_queries".to_string(),
3494+
serde_json::json!([
3495+
"codegraph_index_health — inspect workspace_root, .codegraphignore \
3496+
exclusions, and files_changed_since_index before retrying",
3497+
]),
3498+
);
3499+
}
3500+
}
3501+
Ok(response)
3502+
}
3503+
3504+
// ==================== Index Health ====================
3505+
"codegraph_index_health" => {
3506+
let (node_count, edge_count) = {
3507+
let graph = self.backend.graph.read().await;
3508+
(graph.node_count(), graph.edge_count())
3509+
};
3510+
3511+
let workspace_root = self
3512+
.backend
3513+
.workspace_folders
3514+
.first()
3515+
.map(|p| p.display().to_string());
3516+
3517+
let workspace_revision_hint = self
3518+
.backend
3519+
.workspace_folders
3520+
.first()
3521+
.and_then(|ws| crate::git_mining::GitExecutor::new(ws).ok())
3522+
.and_then(|g| g.head_commit().ok())
3523+
.map(|h| h[..8.min(h.len())].to_string());
3524+
3525+
// Cheap staleness check: re-hash only files we already know
3526+
// about (no directory walk) and count content drift. Doesn't
3527+
// catch brand-new/deleted files — that needs a full walk,
3528+
// which is what reindex itself does; this stays cheap on
3529+
// purpose so it's safe to call before every risky operation.
3530+
let files_changed_since_index = {
3531+
let state = self.backend.index_state.lock().await;
3532+
state
3533+
.all_hashes()
3534+
.iter()
3535+
.filter(|(path, &stored_hash)| {
3536+
match std::fs::read(path) {
3537+
Ok(content) => {
3538+
crate::indexer::Indexer::hash_content(&content) != stored_hash
3539+
}
3540+
Err(_) => true, // file removed/unreadable since indexing
3541+
}
3542+
})
3543+
.count()
3544+
};
3545+
let files_tracked = self.backend.index_state.lock().await.len();
3546+
3547+
let registry = McpBackend::list_indexed_projects().unwrap_or_default();
3548+
let (index_generated_at, other_namespaces): (Option<u64>, Vec<serde_json::Value>) = {
3549+
let mut generated_at = None;
3550+
let mut others = Vec::new();
3551+
for project in registry {
3552+
let slug = project.get("slug").and_then(|v| v.as_str()).unwrap_or("");
3553+
if slug == self.backend.project_slug {
3554+
generated_at = project.get("last_indexed").and_then(|v| v.as_u64());
3555+
} else {
3556+
others.push(project);
3557+
}
3558+
}
3559+
(generated_at, others)
3560+
};
3561+
3562+
let potentially_stale = node_count == 0 || files_changed_since_index > 0;
3563+
3564+
let mut suggested_next_queries: Vec<&str> = Vec::new();
3565+
if node_count == 0 {
3566+
suggested_next_queries.push("codegraph_reindex_workspace(force=true)");
3567+
} else if files_changed_since_index > 0 {
3568+
suggested_next_queries.push("codegraph_reindex_workspace(force=false)");
3569+
}
3570+
3571+
Ok(serde_json::json!({
3572+
"namespace": self.backend.project_slug,
3573+
"workspace_root": workspace_root,
3574+
"node_count": node_count,
3575+
"edge_count": edge_count,
3576+
"generation": crate::memory::graph_db_generation(),
3577+
"index_generated_at": index_generated_at,
3578+
"workspace_revision_hint": workspace_revision_hint,
3579+
"files_tracked": files_tracked,
3580+
"files_changed_since_index": files_changed_since_index,
3581+
"potentially_stale": potentially_stale,
3582+
"other_namespaces": other_namespaces,
3583+
"suggested_next_queries": suggested_next_queries,
34893584
}))
34903585
}
34913586

crates/codegraph-server/src/mcp/tools.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,11 @@ pub fn get_all_tools() -> Vec<Tool> {
142142
find_dead_imports_tool(),
143143
// Ops Struct Tools (1)
144144
find_implementors_tool(),
145-
// Admin Tools (3)
145+
// Admin Tools (4)
146146
reindex_workspace_tool(),
147147
index_files_tool(),
148148
index_directory_tool(),
149+
index_health_tool(),
149150
// PR / Change Analysis (1)
150151
pr_context_tool(),
151152
// Docs Tools (7)
@@ -1236,6 +1237,18 @@ fn reindex_workspace_tool() -> Tool {
12361237
}
12371238
}
12381239

1240+
fn index_health_tool() -> Tool {
1241+
Tool {
1242+
name: "codegraph_index_health".to_string(),
1243+
description: Some("Cheap health/freshness check for the current index — no full reindex, safe to call before any risky sequence of queries. USE WHEN: you got surprisingly few/no results and want to know whether the index is stale or empty before assuming the code doesn't exist, or before a multi-step task where you want to confirm the index is trustworthy up front. Returns: namespace, workspace_root, node_count, edge_count, generation, index_generated_at (unix seconds of last persist), workspace_revision_hint (short git HEAD, if a git repo), files_tracked, files_changed_since_index (cheap re-hash of already-tracked files — does not detect brand-new/deleted files, that needs a full reindex), potentially_stale, other_namespaces (other indexed projects sharing this machine's graph database, for cross-namespace comparison), and suggested_next_queries when the index looks unhealthy. No parameters.".to_string()),
1244+
input_schema: ToolInputSchema {
1245+
schema_type: "object".to_string(),
1246+
properties: None,
1247+
required: None,
1248+
},
1249+
}
1250+
}
1251+
12391252
fn index_files_tool() -> Tool {
12401253
let mut properties = HashMap::new();
12411254
properties.insert(
@@ -1801,14 +1814,14 @@ mod tests {
18011814
fn test_get_all_tools_count() {
18021815
let tools = get_all_tools();
18031816
// Analysis: 12 (incl. probe_symbol), Search: 8, Navigation: 3, Memory: 7,
1804-
// Dead Imports: 1, Ops: 1, Admin: 3, Docs: 7, PR: 1 = 43 community tools
1817+
// Dead Imports: 1, Ops: 1, Admin: 4 (incl. index_health), Docs: 7, PR: 1 = 44 community tools
18051818
// (12 premium tools moved to pro edition: scan_security, analyze_coupling, find_unused_code,
18061819
// find_duplicates, find_similar, cluster_symbols, compare_symbols, cross_project_search,
18071820
// mine_git_history, mine_git_history_for_file, search_git_history)
18081821
assert_eq!(
18091822
tools.len(),
1810-
43,
1811-
"Expected 43 community tools, got {}",
1823+
44,
1824+
"Expected 44 community tools, got {}",
18121825
tools.len()
18131826
);
18141827
}

0 commit comments

Comments
 (0)