From 9f0dfc37847608d4119743a65dbd5fa1d2dc1e7c Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:02:41 +0300 Subject: [PATCH 1/9] fix(memory): treat zero available_memory as unknown, not low-memory sysinfo can report exactly 0 bytes available under memory pressure. The old threshold check (avail < 1_500_000_000) treated that the same as "no memory left" and permanently forced graph-only mode (no embeddings). Extract the check into should_skip_embeddings_for_available_memory() and treat 0 as an unreliable reading instead of OOM, so the server still attempts to load the embedding model. --- crates/codegraph-server/src/mcp/engine.rs | 2 +- crates/codegraph-server/src/memory.rs | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-server/src/mcp/engine.rs b/crates/codegraph-server/src/mcp/engine.rs index a94ffab..cae24ea 100644 --- a/crates/codegraph-server/src/mcp/engine.rs +++ b/crates/codegraph-server/src/mcp/engine.rs @@ -219,7 +219,7 @@ mod imp { let shared_engine = { let mut sys = sysinfo::System::new(); sys.refresh_memory(); - if sys.available_memory() < 1_500_000_000 { + if crate::memory::should_skip_embeddings_for_available_memory(sys.available_memory()) { tracing::warn!("Engine: <1.5 GB free — running graph-only (no shared model)"); None } else { diff --git a/crates/codegraph-server/src/memory.rs b/crates/codegraph-server/src/memory.rs index 10ba1fd..c0a0b37 100644 --- a/crates/codegraph-server/src/memory.rs +++ b/crates/codegraph-server/src/memory.rs @@ -189,6 +189,12 @@ pub struct MemoryManager { embedding_model: codegraph_memory::EmbeddingBackend, } +const MIN_EMBEDDING_FREE_BYTES: u64 = 1_500_000_000; // ~1.5 GB for model + ort runtime + +pub(crate) fn should_skip_embeddings_for_available_memory(avail: u64) -> bool { + avail != 0 && avail < MIN_EMBEDDING_FREE_BYTES +} + impl MemoryManager { /// Create a new MemoryManager pub fn new(extension_path: Option) -> Self { @@ -284,12 +290,11 @@ impl MemoryManager { let mut sys = sysinfo::System::new(); sys.refresh_memory(); let avail = sys.available_memory(); - const MIN_FREE_BYTES: u64 = 1_500_000_000; // ~1.5 GB for model + ort runtime tracing::info!( "[MemoryManager::initialize] available memory: {} MB", avail / 1_000_000 ); - if avail < MIN_FREE_BYTES { + if should_skip_embeddings_for_available_memory(avail) { crate::crash_phase::mark("onnx_skipped_lowmem"); tracing::warn!( "[MemoryManager::initialize] only {} MB free — skipping embedding model to avoid OOM; semantic search disabled (graph-only)", @@ -722,4 +727,14 @@ mod tests { // Invalidate it manager.invalidate(&id, "testing").await.unwrap(); } + + #[test] + fn zero_available_memory_is_treated_as_unknown_not_low_memory() { + assert!( + !should_skip_embeddings_for_available_memory(0), + "a zero reading from sysinfo means unknown/broken probe, not OOM" + ); + assert!(should_skip_embeddings_for_available_memory(1_499_999_999)); + assert!(!should_skip_embeddings_for_available_memory(1_500_000_000)); + } } From 786717ffd1ca1d2dc46e3fb1a6f415f2c793ff9c Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:20:46 +0300 Subject: [PATCH 2/9] fix(mcp): reconcile uri/path resolution, numeric nodeId, and add structured not-found diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several independent but related resolution bugs made uri+line and nodeId lookups unreliable across almost every MCP tool: - nodeId args were read with `.as_str()` only, so a numeric `{"nodeId": 1306}` (the same JSON type codegraph_symbol_search itself returns) silently failed to resolve. Add arg_node_id() to accept both. - The indexer stores node paths relative to the workspace root, while handlers required an absolute file:// URI and compared with `==` — the two never matched. Worse, a failed URI parse silently became an empty string, which could accidentally match synthetic nodes and return an unrelated symbol with no error at all. Add paths_match()/resolve_uri_to_path() and use them everywhere a strict `==`/file:// URI was assumed. - find_nearest_node treated nodes without an explicit line range (e.g. a Python module's CodeFile node) as a 0..0 range, which won every containment check for target_line == 0 and shadowed real symbols. Skip nodes without a line range instead. - Failed resolutions returned one generic "Could not find symbol" message regardless of cause. Add SymbolNotFoundReason (invalid_uri / workspace_not_indexed / file_not_indexed) with a hint and suggested_next_queries(), wired into every uri+line/nodeId tool. - Add a match_confidence ("exact" | "fallback") field, always present, so callers don't have to reconstruct trust level from the optional used_fallback field. - codegraph_reindex_workspace could report status: "success" on a reindex that left the graph empty. Compare node_count after indexing against files processed and mark the response "degraded"; auto-retry once with force=true before returning, since that's what reliably fixes it. --- .../codegraph-server/src/domain/call_graph.rs | 2 + crates/codegraph-server/src/domain/callers.rs | 4 + crates/codegraph-server/src/domain/impact.rs | 2 + .../codegraph-server/src/domain/node_props.rs | 2 - .../src/domain/node_resolution.rs | 298 ++++++++++++- .../src/domain/symbol_info.rs | 2 + .../src/handlers/ai_context.rs | 11 +- crates/codegraph-server/src/mcp/server.rs | 422 +++++++++++------- 8 files changed, 569 insertions(+), 174 deletions(-) diff --git a/crates/codegraph-server/src/domain/call_graph.rs b/crates/codegraph-server/src/domain/call_graph.rs index e7bb5b1..fc40a33 100644 --- a/crates/codegraph-server/src/domain/call_graph.rs +++ b/crates/codegraph-server/src/domain/call_graph.rs @@ -68,6 +68,7 @@ pub(crate) struct CallGraphResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } // ============================================================ @@ -290,6 +291,7 @@ pub(crate) async fn get_call_graph( diagnostic, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } diff --git a/crates/codegraph-server/src/domain/callers.rs b/crates/codegraph-server/src/domain/callers.rs index 4c318f7..9f339df 100644 --- a/crates/codegraph-server/src/domain/callers.rs +++ b/crates/codegraph-server/src/domain/callers.rs @@ -36,6 +36,7 @@ pub(crate) struct CallersResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } /// Result of `get_callees`. @@ -49,6 +50,7 @@ pub(crate) struct CalleesResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } // ============================================================ @@ -117,6 +119,7 @@ pub(crate) async fn get_callers( diagnostic, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } @@ -180,6 +183,7 @@ pub(crate) async fn get_callees( diagnostic, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } diff --git a/crates/codegraph-server/src/domain/impact.rs b/crates/codegraph-server/src/domain/impact.rs index 3d53841..68cf61a 100644 --- a/crates/codegraph-server/src/domain/impact.rs +++ b/crates/codegraph-server/src/domain/impact.rs @@ -90,6 +90,7 @@ pub(crate) struct ImpactResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } // ============================================================ @@ -291,6 +292,7 @@ pub(crate) async fn analyze_impact( warnings, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), } } diff --git a/crates/codegraph-server/src/domain/node_props.rs b/crates/codegraph-server/src/domain/node_props.rs index ef1739e..a37cfee 100644 --- a/crates/codegraph-server/src/domain/node_props.rs +++ b/crates/codegraph-server/src/domain/node_props.rs @@ -21,13 +21,11 @@ pub(crate) fn line_end(node: &Node) -> u32 { } /// Optional variant — returns None when neither key is present. -#[allow(dead_code)] pub(crate) fn line_start_opt(node: &Node) -> Option { line_start_opt_from_props(&node.properties) } /// Optional variant — returns None when neither key is present. -#[allow(dead_code)] pub(crate) fn line_end_opt(node: &Node) -> Option { line_end_opt_from_props(&node.properties) } diff --git a/crates/codegraph-server/src/domain/node_resolution.rs b/crates/codegraph-server/src/domain/node_resolution.rs index 11559ae..dc70acc 100644 --- a/crates/codegraph-server/src/domain/node_resolution.rs +++ b/crates/codegraph-server/src/domain/node_resolution.rs @@ -7,6 +7,52 @@ use codegraph::{CodeGraph, NodeId}; use crate::domain::node_props; +/// Compare two file paths for the "same file" relation, tolerant of one side being +/// relative (as stored by the indexer, e.g. "./crates/foo/bar.rs") and the other +/// absolute (as produced by resolving a `file://` URI from an IDE/MCP client). +/// +/// Falls back to a suffix match on path-component boundaries so `"./a/b.rs"` +/// matches `"/workspace/a/b.rs"` without requiring filesystem canonicalization. +pub(crate) fn paths_match(node_path: &str, query_path: &str) -> bool { + let node_norm = node_path.trim_start_matches("./"); + let query_norm = query_path.trim_start_matches("./"); + if node_norm.is_empty() || query_norm.is_empty() { + return false; + } + if node_norm == query_norm { + return true; + } + if let Some(rest) = query_norm.strip_suffix(node_norm) { + return rest.is_empty() || rest.ends_with('/'); + } + if let Some(rest) = node_norm.strip_suffix(query_norm) { + return rest.is_empty() || rest.ends_with('/'); + } + false +} + +/// Resolve a `uri` argument to a path string usable for graph lookups. +/// +/// MCP clients are expected to send LSP-style `file://` URIs, but plain +/// filesystem paths (relative or absolute) are also accepted — this matters +/// for `--run-tool` one-shot invocations and simpler MCP clients that don't +/// bother with URI encoding. +pub(crate) fn resolve_uri_to_path(uri: &str) -> Option { + if let Ok(url) = tower_lsp::lsp_types::Url::parse(uri) { + if let Ok(path) = url.to_file_path() { + return Some(path.to_string_lossy().to_string()); + } + // Parsed as a URL but not a file:// URI we can resolve to a path — invalid. + if url.scheme() != "file" { + return None; + } + } + if uri.is_empty() { + return None; + } + Some(uri.to_string()) +} + /// Find the nearest function/symbol node at or near a given line in a file. /// /// Strategy: @@ -20,14 +66,22 @@ pub(crate) fn find_nearest_node( target_line: u32, ) -> Option<(NodeId, bool)> { // Strategy 1: Exact containment (prefer tightest — smallest range) + // Only considers nodes with an explicit line range. Container nodes like + // `CodeFile` are often indexed without `line_start`/`line_end` at all; treating + // that absence as a `0..0` range would make them win every containment check + // for `target_line == 0` (range_size 0 beats any real symbol's range) and + // masquerade as the nearest symbol, even though they have no real source span. let mut best_exact: Option<(NodeId, u32)> = None; // (id, range_size) for (&node_id, node) in graph.nodes_iter() { - if node_props::path(node) != file_path { + if !paths_match(node_props::path(node), file_path) { continue; } - let start = node_props::line_start(node); - let end = node_props::line_end(node); + let (Some(start), Some(end)) = + (node_props::line_start_opt(node), node_props::line_end_opt(node)) + else { + continue; + }; if target_line >= start && target_line <= end { let range_size = end.saturating_sub(start); if best_exact.is_none() || range_size < best_exact.unwrap().1 { @@ -42,11 +96,16 @@ pub(crate) fn find_nearest_node( // Strategy 2: Nearest by proximity (prefer forward, penalize backward) let mut best_fallback: Option<(NodeId, i64)> = None; for (&node_id, node) in graph.nodes_iter() { - if node_props::path(node) != file_path { + if !paths_match(node_props::path(node), file_path) { continue; } - let start = node_props::line_start(node) as i64; - let end = node_props::line_end(node) as i64; + let (Some(start), Some(end)) = + (node_props::line_start_opt(node), node_props::line_end_opt(node)) + else { + continue; + }; + let start = start as i64; + let end = end as i64; let target = target_line as i64; let distance = if start > target { @@ -62,3 +121,230 @@ pub(crate) fn find_nearest_node( best_fallback.map(|(id, _)| (id, true)) } + +/// Structured reason why `find_nearest_node` (or a `uri` resolution) failed to +/// find any node, replacing the previous generic "Could not find symbol" +/// message with something an agent can act on without another round trip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SymbolNotFoundReason { + /// `uri` could not be resolved to a filesystem path at all (e.g. a + /// non-`file://` URI or an unparseable one). + InvalidUri, + /// The graph has no nodes at all — nothing has been indexed yet. + WorkspaceNotIndexed, + /// The graph has nodes, but none of them belong to the requested file. + FileNotIndexed, +} + +impl SymbolNotFoundReason { + pub(crate) fn code(self) -> &'static str { + match self { + SymbolNotFoundReason::InvalidUri => "invalid_uri", + SymbolNotFoundReason::WorkspaceNotIndexed => "workspace_not_indexed", + SymbolNotFoundReason::FileNotIndexed => "file_not_indexed", + } + } + + pub(crate) fn hint(self) -> &'static str { + match self { + SymbolNotFoundReason::InvalidUri => { + "Pass a file:// URI or a plain filesystem path (relative or absolute)." + } + SymbolNotFoundReason::WorkspaceNotIndexed => { + "Run codegraph_reindex_workspace(force=true) before querying symbols." + } + SymbolNotFoundReason::FileNotIndexed => { + "This file has no nodes in the graph yet. Run codegraph_index_files \ + for it, or codegraph_reindex_workspace(force=true) if it should \ + already be covered by the workspace root." + } + } + } + + /// Concrete next tool calls likely to resolve this failure — the same + /// guidance as `hint()`, but as machine-actionable tool names instead of + /// prose, so an agent doesn't have to parse English to pick a next step. + pub(crate) fn suggested_next_queries(self) -> &'static [&'static str] { + match self { + SymbolNotFoundReason::InvalidUri => &[], + SymbolNotFoundReason::WorkspaceNotIndexed => { + &["codegraph_reindex_workspace(force=true)"] + } + SymbolNotFoundReason::FileNotIndexed => &[ + "codegraph_index_files", + "codegraph_reindex_workspace(force=true)", + ], + } + } +} + +/// Confidence label for a resolved symbol: `"exact"` when the requested line +/// fell inside the symbol's own range, `"fallback"` when the nearest symbol +/// was substituted instead. Lets an agent decide whether to trust a response +/// without re-deriving it from `used_fallback` itself. +pub(crate) fn match_confidence(used_fallback: bool) -> &'static str { + if used_fallback { + "fallback" + } else { + "exact" + } +} + +/// Diagnose why a `uri`-based lookup found no node, for use in error responses. +pub(crate) fn diagnose_not_found(graph: &CodeGraph, uri: &str) -> SymbolNotFoundReason { + let Some(path) = resolve_uri_to_path(uri) else { + return SymbolNotFoundReason::InvalidUri; + }; + if graph.nodes_iter().next().is_none() { + return SymbolNotFoundReason::WorkspaceNotIndexed; + } + let file_has_nodes = graph + .nodes_iter() + .any(|(_, node)| paths_match(node_props::path(node), &path)); + if !file_has_nodes { + return SymbolNotFoundReason::FileNotIndexed; + } + // File has nodes but find_nearest_node still returned None — shouldn't + // happen given the fallback strategy has no distance limit, but keep a + // safe default rather than panicking on an invariant we can't fully prove. + SymbolNotFoundReason::FileNotIndexed +} + +#[cfg(test)] +mod tests { + use super::{diagnose_not_found, find_nearest_node, match_confidence, paths_match, SymbolNotFoundReason}; + use codegraph::{CodeGraph, NodeType, PropertyMap}; + + #[test] + fn suggested_next_queries_nonempty_for_actionable_reasons() { + assert!(SymbolNotFoundReason::InvalidUri + .suggested_next_queries() + .is_empty()); + assert_eq!( + SymbolNotFoundReason::WorkspaceNotIndexed.suggested_next_queries(), + &["codegraph_reindex_workspace(force=true)"] + ); + assert_eq!( + SymbolNotFoundReason::FileNotIndexed + .suggested_next_queries() + .len(), + 2 + ); + } + + #[test] + fn match_confidence_exact_vs_fallback() { + assert_eq!(match_confidence(false), "exact"); + assert_eq!(match_confidence(true), "fallback"); + } + + #[test] + fn diagnose_not_found_invalid_uri() { + let graph = CodeGraph::in_memory().unwrap(); + assert_eq!( + diagnose_not_found(&graph, "not-a-uri://???"), + SymbolNotFoundReason::InvalidUri + ); + } + + #[test] + fn diagnose_not_found_empty_workspace() { + let graph = CodeGraph::in_memory().unwrap(); + assert_eq!( + diagnose_not_found(&graph, "/workspace/src/lib.rs"), + SymbolNotFoundReason::WorkspaceNotIndexed + ); + } + + #[test] + fn diagnose_not_found_file_not_indexed() { + let mut graph = CodeGraph::in_memory().unwrap(); + let props = PropertyMap::new() + .with("name", "other") + .with("path", "./crates/foo/other.rs"); + graph.add_node(NodeType::Function, props).unwrap(); + + assert_eq!( + diagnose_not_found(&graph, "/workspace/crates/foo/missing.rs"), + SymbolNotFoundReason::FileNotIndexed + ); + } + + #[test] + fn find_nearest_node_skips_codefile_node_without_line_range() { + // Regression test: a `CodeFile` node indexed without `line_start`/`line_end` + // (e.g. Python's module node, see codegraph-python/src/parser_impl.rs) must + // not shadow a real function when querying line 0 — its absent range used + // to default to 0..0, which trivially "contains" line 0 with the smallest + // possible range and won the containment check over any real symbol. + let mut graph = CodeGraph::in_memory().unwrap(); + let file_props = PropertyMap::new() + .with("name", "init_arch_workflow") + .with("path", "./app/services/init_arch_workflow.py") + .with("language", "python"); + graph.add_node(NodeType::CodeFile, file_props).unwrap(); + + let fn_props = PropertyMap::new() + .with("name", "init_arch_workflow") + .with("path", "./app/services/init_arch_workflow.py") + .with("line_start", "1") + .with("line_end", "40"); + let fn_id = graph.add_node(NodeType::Function, fn_props).unwrap(); + + let (node_id, used_fallback) = + find_nearest_node(&graph, "./app/services/init_arch_workflow.py", 0) + .expect("expected a match"); + assert_eq!(node_id, fn_id); + assert!(used_fallback, "line 0 is outside the function's own range"); + } + + #[test] + fn find_nearest_node_returns_none_when_only_codefile_node_present() { + // If a file has no indexed symbols at all — only its `CodeFile` node — the + // caller should get a proper "not found" instead of a fake match on the + // file node's fabricated 0..0 range. + let mut graph = CodeGraph::in_memory().unwrap(); + let file_props = PropertyMap::new() + .with("name", "empty_module") + .with("path", "./app/empty_module.py") + .with("language", "python"); + graph.add_node(NodeType::CodeFile, file_props).unwrap(); + + assert!(find_nearest_node(&graph, "./app/empty_module.py", 0).is_none()); + } + + #[test] + fn identical_paths_match() { + assert!(paths_match("./crates/foo/bar.rs", "./crates/foo/bar.rs")); + assert!(paths_match("crates/foo/bar.rs", "crates/foo/bar.rs")); + } + + #[test] + fn relative_matches_absolute_on_boundary() { + assert!(paths_match( + "./crates/foo/bar.rs", + "/workspace/crates/foo/bar.rs" + )); + assert!(paths_match( + "/workspace/crates/foo/bar.rs", + "./crates/foo/bar.rs" + )); + } + + #[test] + fn does_not_match_on_partial_component() { + // "bar.rs" must not match "notbar.rs" — boundary must fall on a '/' + assert!(!paths_match("./crates/foo/notbar.rs", "bar.rs")); + assert!(!paths_match( + "./crates/other/bar.rs", + "./crates/foo/bar.rs" + )); + } + + #[test] + fn empty_paths_never_match() { + assert!(!paths_match("", "")); + assert!(!paths_match("./crates/foo/bar.rs", "")); + assert!(!paths_match("", "./crates/foo/bar.rs")); + } +} diff --git a/crates/codegraph-server/src/domain/symbol_info.rs b/crates/codegraph-server/src/domain/symbol_info.rs index c7d11e4..82057b1 100644 --- a/crates/codegraph-server/src/domain/symbol_info.rs +++ b/crates/codegraph-server/src/domain/symbol_info.rs @@ -40,6 +40,7 @@ pub(crate) struct SymbolInfoResult { pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, } /// Result of `get_detailed_symbol`. @@ -117,6 +118,7 @@ pub(crate) async fn get_symbol_info( reference_count: info.reference_count, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), }) } diff --git a/crates/codegraph-server/src/handlers/ai_context.rs b/crates/codegraph-server/src/handlers/ai_context.rs index 58f6695..b1ff51a 100644 --- a/crates/codegraph-server/src/handlers/ai_context.rs +++ b/crates/codegraph-server/src/handlers/ai_context.rs @@ -7,7 +7,7 @@ use crate::backend::CodeGraphBackend; use crate::domain::ai_context::AiContextResult; use serde::{Deserialize, Serialize}; use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::{Position, Range, Url}; +use tower_lsp::lsp_types::{Position, Range}; // ========================================== // AI Context Request Types (public — used externally) @@ -51,12 +51,8 @@ impl CodeGraphBackend { &self, params: AIContextParams, ) -> Result { - let uri = Url::parse(¶ms.uri) - .map_err(|_| tower_lsp::jsonrpc::Error::invalid_params("Invalid URI"))?; - - let path = uri - .to_file_path() - .map_err(|_| tower_lsp::jsonrpc::Error::invalid_params("Invalid file path"))?; + let path_str = crate::domain::node_resolution::resolve_uri_to_path(¶ms.uri) + .ok_or_else(|| tower_lsp::jsonrpc::Error::invalid_params("Invalid URI"))?; let line = if let Some(l) = params.line { l @@ -68,7 +64,6 @@ impl CodeGraphBackend { let intent = params.intent.as_deref().unwrap_or("explain"); let max_tokens = params.max_tokens.unwrap_or(4000); - let path_str = path.to_string_lossy().to_string(); let graph = self.graph.read().await; diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index e244e72..cffdb77 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -2057,7 +2057,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let depth = args .get("depth") .and_then(|v| v.as_u64()) @@ -2065,8 +2065,8 @@ impl McpServer { .unwrap_or(1); // Use fallback for uri+line, exact match for node_id - let (start_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (start_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2088,10 +2088,14 @@ impl McpServer { .await; Ok(serde_json::to_value(&result).unwrap_or_default()) } else { - Ok(serde_json::json!({ - "callers": [], - "message": "Could not find starting node. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "callers": [], + "message": "Could not find starting node. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2101,7 +2105,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let depth = args .get("depth") .and_then(|v| v.as_u64()) @@ -2109,8 +2113,8 @@ impl McpServer { .unwrap_or(1); // Use fallback for uri+line, exact match for node_id - let (start_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (start_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2132,10 +2136,14 @@ impl McpServer { .await; Ok(serde_json::to_value(&result).unwrap_or_default()) } else { - Ok(serde_json::json!({ - "callees": [], - "message": "Could not find starting node. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "callees": [], + "message": "Could not find starting node. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2146,7 +2154,7 @@ impl McpServer { .get("startNodeId") .or_else(|| args.get("nodeId")) .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let direction_str = args .get("direction") .and_then(|v| v.as_str()) @@ -2164,8 +2172,8 @@ impl McpServer { .unwrap_or(100); // Use fallback for uri+line, exact match for node_id - let (start_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (start_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2271,11 +2279,15 @@ impl McpServer { Ok(response) } } else { - Ok(serde_json::json!({ - "nodes": [], - "edges": [], - "message": "Could not find starting node. Provide either startNodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "nodes": [], + "edges": [], + "message": "Could not find starting node. Provide either startNodeId or uri+line." + }), + reason, + )) } } @@ -2285,7 +2297,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let include_refs = args .get("includeReferences") .or_else(|| args.get("include_references")) @@ -2293,8 +2305,8 @@ impl McpServer { .unwrap_or(false); // Use fallback for uri+line, exact match for node_id - let (target_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (target_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2321,9 +2333,13 @@ impl McpServer { })), } } else { - Ok(serde_json::json!({ - "error": "Could not find symbol. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "error": "Could not find symbol. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2333,7 +2349,7 @@ impl McpServer { let node_id = args .get("nodeId") .or_else(|| args.get("node_id")) - .and_then(|v| v.as_str()); + .and_then(arg_node_id); let include_source = args .get("includeSource") .or_else(|| args.get("include_source")) @@ -2351,8 +2367,8 @@ impl McpServer { .unwrap_or(true); // Use fallback for uri+line, exact match for node_id - let (target_node, used_fallback) = if let Some(id_str) = node_id { - (parse_node_id(id_str), false) + let (target_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) } else if let (Some(u), Some(l)) = (uri, line) { match self.find_nearest_node_with_fallback(u, l).await { Some((id, fallback)) => (Some(id), fallback), @@ -2376,9 +2392,13 @@ impl McpServer { .await; Ok(serde_json::to_value(&result).unwrap_or_default()) } else { - Ok(serde_json::json!({ - "error": "Could not find symbol. Provide either nodeId or uri+line." - })) + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "error": "Could not find symbol. Provide either nodeId or uri+line." + }), + reason, + )) } } @@ -2408,12 +2428,8 @@ impl McpServer { .unwrap_or(false); let typed_result = { - let url = tower_lsp::lsp_types::Url::parse(uri) - .map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; - let path_str = path.to_string_lossy().to_string(); + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or("Invalid URI")?; let graph = self.backend.graph.read().await; crate::domain::dependency_graph::get_dependency_graph( &graph, &path_str, depth, direction, @@ -2480,11 +2496,17 @@ impl McpServer { .await; serde_json::to_value(&typed).unwrap_or_default() } - None => serde_json::json!({ - "nodes": [], - "edges": [], - "message": "Could not find symbol at location" - }), + None => { + let reason = self.diagnose_not_found_reason(Some(uri)).await; + Self::attach_not_found_reason( + serde_json::json!({ + "nodes": [], + "edges": [], + "message": "Could not find symbol at location" + }), + reason, + ) + } }; if summary { @@ -2567,11 +2589,17 @@ impl McpServer { .await; serde_json::to_value(&typed).unwrap_or_default() } - None => serde_json::json!({ - "impacted": [], - "risk_level": "unknown", - "message": "Could not find symbol at location" - }), + None => { + let reason = self.diagnose_not_found_reason(Some(uri)).await; + Self::attach_not_found_reason( + serde_json::json!({ + "impacted": [], + "risk_level": "unknown", + "message": "Could not find symbol at location" + }), + reason, + ) + } }; if summary { @@ -2633,19 +2661,21 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(4000); - let url = - tower_lsp::lsp_types::Url::parse(uri).map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; - let path_str = path.to_string_lossy().to_string(); + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or_else(|| "Invalid URI".to_string())?; + let path = std::path::Path::new(&path_str); let graph = self.backend.graph.read().await; let result = crate::domain::ai_context::get_ai_context( &graph, &path_str, line, intent, max_tokens, ) .ok_or_else(|| { - format!("No symbols found in '{uri}'. Try indexing the workspace first.") + let reason = crate::domain::node_resolution::diagnose_not_found(&graph, uri); + format!( + "No symbols found in '{uri}' ({}). {}", + reason.code(), + reason.hint() + ) })?; let mut json = serde_json::to_value(result).map_err(|e| e.to_string())?; @@ -2713,11 +2743,8 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(8000); - let file_path = tower_lsp::lsp_types::Url::parse(uri) - .ok() - .and_then(|u| u.to_file_path().ok()) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); + let file_path = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or("Invalid URI")?; let result = crate::domain::edit_context::get_edit_context( &self.backend.graph, &self.backend.query_engine, @@ -2754,12 +2781,8 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(5); - let anchor_path: Option = uri.and_then(|u| { - tower_lsp::lsp_types::Url::parse(u) - .ok() - .and_then(|parsed| parsed.to_file_path().ok()) - .map(|p| p.to_string_lossy().to_string()) - }); + let anchor_path: Option = + uri.and_then(crate::domain::node_resolution::resolve_uri_to_path); let result = crate::domain::curated_context::get_curated_context( &self.backend.graph, &self.backend.query_engine, @@ -2792,46 +2815,40 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(10); - // Resolve file path - let url = match tower_lsp::lsp_types::Url::parse(uri) { - Ok(u) => u, - Err(_) => { + // Resolve file path (accepts file:// URIs and plain paths) + let path_str = match crate::domain::node_resolution::resolve_uri_to_path(uri) { + Some(p) => p, + None => { return Ok(serde_json::json!({ "tests": [], "message": "Invalid URI" })) } }; - let file_path = match url.to_file_path() { - Ok(p) => p, - Err(_) => { - return Ok(serde_json::json!({ - "tests": [], - "message": "Invalid file path" - })) - } - }; - let path_str = file_path.to_string_lossy().to_string(); - // Resolve target node (with fallback to nearest symbol) - let (target_node_id, used_fallback, symbol_name) = + // Resolve target node (with fallback to nearest symbol). Prefer the + // node's own stored `path` property over the resolved URI for the + // exact-match query below, since the graph may index files under a + // relative path while the resolved URI is absolute (or vice versa). + let (target_node_id, used_fallback, symbol_name, resolved_path) = match self.find_nearest_node_with_fallback(uri, line).await { Some((id, fallback)) => { - let name = { - let graph = self.backend.graph.read().await; - graph - .get_node(id) - .ok() - .map(|n| node_props::name(n).to_string()) - .unwrap_or_default() - }; - (Some(id), fallback, name) + let graph = self.backend.graph.read().await; + let node = graph.get_node(id).ok(); + let name = node + .map(|n| node_props::name(n).to_string()) + .unwrap_or_default(); + let node_path = node + .map(|n| node_props::path(n).to_string()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| path_str.clone()); + (Some(id), fallback, name, node_path) } - None => (None, false, String::new()), + None => (None, false, String::new(), path_str.clone()), }; let params = crate::domain::related_tests::FindRelatedTestsParams { - path: path_str.clone(), + path: resolved_path, target_node_id, limit, }; @@ -2903,18 +2920,19 @@ impl McpServer { .and_then(|v| v.as_bool()) .unwrap_or(false); - let url = - tower_lsp::lsp_types::Url::parse(uri).map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri) + .ok_or("Invalid URI")?; let graph = self.backend.graph.read().await; - let path_str = path.to_string_lossy().to_string(); - let file_nodes = graph - .query() - .property("path", path_str) - .execute() - .unwrap_or_default(); + let file_nodes: Vec = graph + .nodes_iter() + .filter(|(_, node)| { + crate::domain::node_resolution::paths_match( + node_props::path(node), + &path_str, + ) + }) + .map(|(&id, _)| id) + .collect(); let result = crate::handlers::metrics::analyze_file_complexity( &graph, &file_nodes, @@ -3132,12 +3150,8 @@ impl McpServer { .unwrap_or(5); // Find code nodes at the given location and search for related memories - let url = - tower_lsp::lsp_types::Url::parse(uri).map_err(|_| "Invalid URI".to_string())?; - let path = url - .to_file_path() - .map_err(|_| "Invalid file path".to_string())?; - let path_str = path.to_string_lossy().to_string(); + let path_str = + crate::domain::node_resolution::resolve_uri_to_path(uri).ok_or("Invalid URI")?; // Search for memories related to this file let current_only = args @@ -3323,48 +3337,69 @@ impl McpServer { // ==================== Admin Tools ==================== "codegraph_reindex_workspace" => { let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); - tracing::info!("Reindexing workspace (force={})...", force); - - if force { - // Force: clear graph and hash cache for full rebuild - { - let mut graph = self.backend.graph.write().await; - *graph = codegraph::CodeGraph::in_memory() - .map_err(|e| format!("Failed to create new graph: {}", e))?; - } - self.backend.index_state.lock().await.clear(); - } - // else: incremental — index_file skips unchanged files via hash cache - // Reindex the workspace - let (total, parsed) = self.backend.index_workspace().await; - tracing::info!( - "Reindexed: {} total, {} parsed, {} skipped", - total, - parsed, - total - parsed - ); + let (total, parsed, node_count_after) = self.do_reindex_workspace(force).await?; + let mut degraded = total > 0 && node_count_after == 0; + + // A soft (force=false) reindex that left the graph empty is almost + // always stale hash-cache/state, not a real parsing failure — the + // same situation `force=true` exists to fix. Auto-retry once so + // callers don't have to learn "degraded means call me again with + // force=true" the hard way. + let (total, parsed, node_count_after, auto_retried) = if degraded && !force { + tracing::warn!( + "Soft reindex left the graph empty ({} files processed, 0 nodes) — \ + auto-retrying with force=true", + total + ); + let (total2, parsed2, node_count_after2) = + self.do_reindex_workspace(true).await?; + degraded = total2 > 0 && node_count_after2 == 0; + (total2, parsed2, node_count_after2, true) + } else { + (total, parsed, node_count_after, false) + }; - // Embed any new/changed symbols so semantic search reflects the reindex. - if !self.backend.graph_only { - self.backend.query_engine.embed_missing_symbols().await; - self.backend.query_engine.prune_orphan_vectors().await; - if let Err(e) = self - .backend - .query_engine - .save_symbol_vectors(&self.backend.project_slug) - .await - { - tracing::warn!("Failed to persist vectors after reindex: {}", e); - } - } + let status = if degraded { "degraded" } else { "success" }; + let message = if degraded && auto_retried { + format!( + "Reindex reported {} files processed, but the graph has 0 nodes afterwards \ + even after an automatic force=true retry. The workspace root, \ + .codegraphignore, or --max-files may be excluding everything.", + total + ) + } else if degraded { + format!( + "Reindex reported {} files processed, but the graph has 0 nodes afterwards \ + even with force=true. The workspace root, .codegraphignore, or \ + --max-files may be excluding everything.", + total + ) + } else if auto_retried { + format!( + "Soft reindex found a stale/empty graph; auto-retried with force=true. \ + Reindexed {} files ({} changed, {} skipped)", + total, + parsed, + total - parsed + ) + } else { + format!( + "Reindexed {} files ({} changed, {} skipped)", + total, + parsed, + total - parsed + ) + }; Ok(serde_json::json!({ - "status": "success", - "message": format!("Reindexed {} files ({} changed, {} skipped)", total, parsed, total - parsed), + "status": status, + "message": message, "files_indexed": total, "files_parsed": parsed, - "files_skipped": total - parsed + "files_skipped": total - parsed, + "node_count_after": node_count_after, + "auto_retried_with_force": auto_retried })) } @@ -4615,9 +4650,7 @@ impl McpServer { let file_path: Option = args .get("uri") .and_then(|v| v.as_str()) - .and_then(|uri| tower_lsp::lsp_types::Url::parse(uri).ok()) - .and_then(|url| url.to_file_path().ok()) - .map(|p| p.to_string_lossy().to_string()); + .and_then(crate::domain::node_resolution::resolve_uri_to_path); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; let typed_result = { @@ -4724,6 +4757,49 @@ impl McpServer { } /// Find a node at location with broader fallback, returning whether fallback was used. + /// Reindex the workspace, optionally clearing the graph/hash-cache first. + /// + /// Returns `(files_indexed, files_parsed, node_count_after)`. + async fn do_reindex_workspace(&self, force: bool) -> Result<(usize, usize, usize), String> { + tracing::info!("Reindexing workspace (force={})...", force); + + if force { + // Force: clear graph and hash cache for full rebuild + { + let mut graph = self.backend.graph.write().await; + *graph = codegraph::CodeGraph::in_memory() + .map_err(|e| format!("Failed to create new graph: {}", e))?; + } + self.backend.index_state.lock().await.clear(); + } + // else: incremental — index_file skips unchanged files via hash cache + + let (total, parsed) = self.backend.index_workspace().await; + tracing::info!( + "Reindexed: {} total, {} parsed, {} skipped", + total, + parsed, + total - parsed + ); + + // Embed any new/changed symbols so semantic search reflects the reindex. + if !self.backend.graph_only { + self.backend.query_engine.embed_missing_symbols().await; + self.backend.query_engine.prune_orphan_vectors().await; + if let Err(e) = self + .backend + .query_engine + .save_symbol_vectors(&self.backend.project_slug) + .await + { + tracing::warn!("Failed to persist vectors after reindex: {}", e); + } + } + + let node_count_after = self.backend.graph.read().await.node_count(); + Ok((total, parsed, node_count_after)) + } + /// /// Strategy: /// 1. First try exact match (line within symbol's range) @@ -4735,13 +4811,44 @@ impl McpServer { uri: &str, line: u32, ) -> Option<(codegraph::NodeId, bool)> { - let url = tower_lsp::lsp_types::Url::parse(uri).ok()?; - let path = url.to_file_path().ok()?; - let path_str = path.to_string_lossy().to_string(); + let path_str = crate::domain::node_resolution::resolve_uri_to_path(uri)?; let graph = self.backend.graph.read().await; crate::domain::node_resolution::find_nearest_node(&graph, &path_str, line) } + /// Diagnose why a `uri`-based lookup found nothing, for use in "not found" + /// error responses. Returns `None` if no `uri` was given (e.g. a bad + /// `nodeId` alone doesn't carry enough information to diagnose). + async fn diagnose_not_found_reason( + &self, + uri: Option<&str>, + ) -> Option { + let uri = uri?; + let graph = self.backend.graph.read().await; + Some(crate::domain::node_resolution::diagnose_not_found( + &graph, uri, + )) + } + + /// Merge a diagnosed not-found reason (if any) into an error response object. + fn attach_not_found_reason( + mut value: Value, + reason: Option, + ) -> Value { + if let (Some(reason), Some(obj)) = (reason, value.as_object_mut()) { + obj.insert("reason".to_string(), serde_json::json!(reason.code())); + obj.insert("hint".to_string(), serde_json::json!(reason.hint())); + let suggestions = reason.suggested_next_queries(); + if !suggestions.is_empty() { + obj.insert( + "suggested_next_queries".to_string(), + serde_json::json!(suggestions), + ); + } + } + value + } + /// Build a memory node from parameters fn build_memory_node( &self, @@ -4910,10 +5017,9 @@ impl McpServer { } } -/// Parse a string into a NodeId -fn parse_node_id(s: &str) -> Option { - // NodeId is u64 in codegraph - s.parse::().ok() +/// Read a nodeId argument that MCP clients may send as either a JSON string or a JSON number. +fn arg_node_id(v: &serde_json::Value) -> Option { + v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())) } #[cfg(test)] From c78906b8f203466926ddc7f9d548ac8b4163aa5b Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:23:06 +0300 Subject: [PATCH 3/9] feat(mcp): add codegraph_probe_symbol tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated, minimal-cost way to check "did uri+line/nodeId resolve to the symbol I expect" before paying for a full get_symbol_info or get_detailed_symbol call. Returns only name/type/node_id/uri/line_start/ line_end/used_fallback/match_confidence — no source, no callers/callees, no graph traversal. Added to the Core tool profile. --- crates/codegraph-server/src/domain/mod.rs | 1 + .../src/domain/probe_symbol.rs | 89 +++++++++++++++++++ crates/codegraph-server/src/mcp/server.rs | 37 ++++++++ crates/codegraph-server/src/mcp/tools.rs | 41 +++++++-- 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 crates/codegraph-server/src/domain/probe_symbol.rs diff --git a/crates/codegraph-server/src/domain/mod.rs b/crates/codegraph-server/src/domain/mod.rs index 67aa3f1..5178795 100644 --- a/crates/codegraph-server/src/domain/mod.rs +++ b/crates/codegraph-server/src/domain/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod module_summary; pub(crate) mod node_props; pub(crate) mod node_resolution; pub(crate) mod pattern_search; +pub(crate) mod probe_symbol; pub(crate) mod related_tests; pub(crate) mod source_code; pub(crate) mod symbol_info; diff --git a/crates/codegraph-server/src/domain/probe_symbol.rs b/crates/codegraph-server/src/domain/probe_symbol.rs new file mode 100644 index 0000000..cb97ff0 --- /dev/null +++ b/crates/codegraph-server/src/domain/probe_symbol.rs @@ -0,0 +1,89 @@ +// Copyright 2025-2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +//! Cheap symbol probe — transport-agnostic. +//! +//! A lightweight companion to `get_symbol_info`/`get_detailed_symbol` for +//! agents that only need to confirm "did I land on the right symbol?" +//! before paying for a heavier, full-context call. + +use crate::domain::{node_props, node_resolution}; +use codegraph::{CodeGraph, NodeId}; +use serde::Serialize; + +/// Result of `codegraph_probe_symbol`. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ProbeSymbolResult { + pub name: String, + #[serde(rename = "type")] + pub symbol_type: String, + pub node_id: String, + pub uri: String, + pub line_start: i64, + pub line_end: i64, + pub used_fallback: bool, + pub match_confidence: &'static str, +} + +/// Build a probe result for an already-resolved node. Cheap by construction: +/// only reads properties already held in memory, no source/callers/callees +/// queries. +pub(crate) fn probe_symbol( + graph: &CodeGraph, + node_id: NodeId, + used_fallback: bool, +) -> Option { + let node = graph.get_node(node_id).ok()?; + Some(ProbeSymbolResult { + name: node_props::name(node).to_string(), + symbol_type: format!("{:?}", node.node_type).to_lowercase(), + node_id: node_id.to_string(), + uri: node_props::path(node).to_string(), + line_start: node_props::line_start(node) as i64, + line_end: node_props::line_end(node) as i64, + used_fallback, + match_confidence: node_resolution::match_confidence(used_fallback), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codegraph::{NodeType, PropertyMap}; + + #[test] + fn probes_a_resolved_node() { + let mut graph = CodeGraph::in_memory().unwrap(); + let props = PropertyMap::new() + .with("name", "compile_navigation") + .with("path", "./a/b.py") + .with("line_start", 122) + .with("line_end", 154); + let id = graph.add_node(NodeType::Function, props).unwrap(); + + let result = probe_symbol(&graph, id, false).unwrap(); + assert_eq!(result.name, "compile_navigation"); + assert_eq!(result.symbol_type, "function"); + assert_eq!(result.line_start, 122); + assert_eq!(result.line_end, 154); + assert!(!result.used_fallback); + assert_eq!(result.match_confidence, "exact"); + } + + #[test] + fn probe_marks_fallback_confidence() { + let mut graph = CodeGraph::in_memory().unwrap(); + let props = PropertyMap::new().with("name", "f").with("path", "./a.py"); + let id = graph.add_node(NodeType::Function, props).unwrap(); + + let result = probe_symbol(&graph, id, true).unwrap(); + assert!(result.used_fallback); + assert_eq!(result.match_confidence, "fallback"); + } + + #[test] + fn probe_returns_none_for_missing_node() { + let graph = CodeGraph::in_memory().unwrap(); + assert!(probe_symbol(&graph, 999_999u64, false).is_none()); + } +} diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index cffdb77..cc8d0f6 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -2291,6 +2291,43 @@ impl McpServer { } } + "codegraph_probe_symbol" => { + let uri = args.get("uri").and_then(|v| v.as_str()); + let line = args.get("line").and_then(|v| v.as_u64()).map(|v| v as u32); + let node_id = args + .get("nodeId") + .or_else(|| args.get("node_id")) + .and_then(arg_node_id); + + let (target_node, used_fallback) = if let Some(id) = node_id { + (Some(id), false) + } else if let (Some(u), Some(l)) = (uri, line) { + match self.find_nearest_node_with_fallback(u, l).await { + Some((id, fallback)) => (Some(id), fallback), + None => (None, false), + } + } else { + (None, false) + }; + + if let Some(node_id) = target_node { + let graph = self.backend.graph.read().await; + match crate::domain::probe_symbol::probe_symbol(&graph, node_id, used_fallback) + { + Some(result) => Ok(serde_json::to_value(&result).unwrap_or_default()), + None => Ok(serde_json::json!({ "error": "Symbol not found" })), + } + } else { + let reason = self.diagnose_not_found_reason(uri).await; + Ok(Self::attach_not_found_reason( + serde_json::json!({ + "error": "Could not find symbol. Provide either nodeId or uri+line." + }), + reason, + )) + } + } + "codegraph_get_symbol_info" => { let uri = args.get("uri").and_then(|v| v.as_str()); let line = args.get("line").and_then(|v| v.as_u64()).map(|v| v as u32); diff --git a/crates/codegraph-server/src/mcp/tools.rs b/crates/codegraph-server/src/mcp/tools.rs index 6b61ecd..004c042 100644 --- a/crates/codegraph-server/src/mcp/tools.rs +++ b/crates/codegraph-server/src/mcp/tools.rs @@ -59,6 +59,7 @@ pub fn tool_in_profile(name: &str, profile: ToolProfile) -> bool { Core => matches!( name, "codegraph_symbol_search" + | "codegraph_probe_symbol" | "codegraph_get_symbol_info" | "codegraph_get_detailed_symbol" | "codegraph_get_ai_context" @@ -103,7 +104,7 @@ pub fn tool_in_profile(name: &str, profile: ToolProfile) -> bool { /// Get all available CodeGraph tools pub fn get_all_tools() -> Vec { vec![ - // Analysis Tools (11) + // Analysis Tools (12) get_dependency_graph_tool(), get_call_graph_tool(), analyze_impact_tool(), @@ -112,6 +113,7 @@ pub fn get_all_tools() -> Vec { get_curated_context_tool(), find_related_tests_tool(), get_symbol_info_tool(), + probe_symbol_tool(), analyze_complexity_tool(), get_module_summary_tool(), find_circular_deps_tool(), @@ -508,6 +510,34 @@ fn get_symbol_info_tool() -> Tool { } } +fn probe_symbol_tool() -> Tool { + let mut properties = HashMap::new(); + properties.insert( + "uri".to_string(), + string_prop( + "The file URI containing the symbol (e.g. file:///Users/me/project/src/main.rs)", + ), + ); + properties.insert( + "line".to_string(), + number_prop("Line number of the symbol (0-indexed)", None), + ); + properties.insert( + "nodeId".to_string(), + string_prop("Internal node ID from symbol_search. Alternative to uri+line."), + ); + + Tool { + name: "codegraph_probe_symbol".to_string(), + description: Some("Cheapest possible symbol lookup — confirms what you'd resolve to before paying for a heavier call. USE WHEN: you're about to call get_symbol_info/get_detailed_symbol/get_edit_context and want to check first whether uri+line lands on the symbol you expect, or whether it'll fall back to a nearby one. Returns only: name, type, node_id, uri, line_start, line_end, used_fallback, match_confidence (\"exact\" | \"fallback\") — no source, no callers/callees, no graph traversal. Requires uri+line or nodeId.".to_string()), + input_schema: ToolInputSchema { + schema_type: "object".to_string(), + properties: Some(properties), + required: None, + }, + } +} + fn analyze_complexity_tool() -> Tool { let mut properties = HashMap::new(); properties.insert( @@ -1728,14 +1758,15 @@ mod tests { #[test] fn test_get_all_tools_count() { let tools = get_all_tools(); - // Analysis: 11, Search: 8, Navigation: 3, Memory: 7, Dead Imports: 1, Ops: 1, Admin: 3, Docs: 7, PR: 1 = 42 community tools + // Analysis: 12 (incl. probe_symbol), Search: 8, Navigation: 3, Memory: 7, + // Dead Imports: 1, Ops: 1, Admin: 3, Docs: 7, PR: 1 = 43 community tools // (12 premium tools moved to pro edition: scan_security, analyze_coupling, find_unused_code, // find_duplicates, find_similar, cluster_symbols, compare_symbols, cross_project_search, // mine_git_history, mine_git_history_for_file, search_git_history) assert_eq!( tools.len(), - 42, - "Expected 42 community tools, got {}", + 43, + "Expected 43 community tools, got {}", tools.len() ); } @@ -1825,7 +1856,7 @@ mod tests { .iter() .filter(|t| tool_in_profile(&t.name, ToolProfile::Core)) .collect(); - assert_eq!(kept.len(), 8, "Core profile should expose 8 tools"); + assert_eq!(kept.len(), 9, "Core profile should expose 9 tools"); // Spot-check key inclusions. assert!(kept.iter().any(|t| t.name == "codegraph_symbol_search")); assert!(kept.iter().any(|t| t.name == "codegraph_get_ai_context")); From f59b6c3dd3eaac656b7a0e7b498a123c1dd9cd44 Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:25:16 +0300 Subject: [PATCH 4/9] fix(mcp): dedupe callers/callees in get_detailed_symbol, add compact mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_detailed_symbol called query_engine.get_symbol_info(node_id), which already computes callers/callees at depth 1 and embeds them in the nested symbol object, and then called get_callers/get_callees again with the same arguments for the top-level fields — the response contained every caller twice (once nested, once top-level) plus a redundant graph traversal. Introduce DetailedSymbolMeta (the same metadata, without callers/callees) and derive the top-level callers/callees from the single get_symbol_info call instead. Add an opt-in `compact` flag that caps callers/callees at 5 entries each (with `*_truncated` counts) for symbols with large caller lists, where that list dominates response size. --- .../src/domain/symbol_info.rs | 171 ++++++++++++++++-- .../codegraph-server/src/handlers/ai_query.rs | 1 + crates/codegraph-server/src/mcp/server.rs | 5 + crates/codegraph-server/src/mcp/tools.rs | 11 +- 4 files changed, 171 insertions(+), 17 deletions(-) diff --git a/crates/codegraph-server/src/domain/symbol_info.rs b/crates/codegraph-server/src/domain/symbol_info.rs index 82057b1..e98b91f 100644 --- a/crates/codegraph-server/src/domain/symbol_info.rs +++ b/crates/codegraph-server/src/domain/symbol_info.rs @@ -43,22 +43,76 @@ pub(crate) struct SymbolInfoResult { pub match_confidence: &'static str, } +/// Symbol metadata for `get_detailed_symbol`, deliberately *without* +/// `callers`/`callees` — those live once, at the top level of +/// `DetailedSymbolResult`, instead of being duplicated here too. Both this +/// struct and the top-level fields used to be populated from independent +/// depth-1 queries that returned identical data. +#[derive(Debug, Serialize)] +pub(crate) struct DetailedSymbolMeta { + pub symbol: SymbolInfo, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dependencies: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dependents: Vec, + pub complexity: Option, + pub lines_of_code: usize, + pub has_tests: bool, + pub is_public: bool, + pub is_deprecated: bool, + pub reference_count: usize, +} + +impl From for DetailedSymbolMeta { + fn from(info: DetailedSymbolInfo) -> Self { + DetailedSymbolMeta { + symbol: info.symbol, + dependencies: info.dependencies, + dependents: info.dependents, + complexity: info.complexity, + lines_of_code: info.lines_of_code, + has_tests: info.has_tests, + is_public: info.is_public, + is_deprecated: info.is_deprecated, + reference_count: info.reference_count, + } + } +} + /// Result of `get_detailed_symbol`. #[derive(Debug, Serialize)] pub(crate) struct DetailedSymbolResult { - /// Full symbol info (serializes as a nested object matching `DetailedSymbolInfo` shape). + /// Symbol metadata (name, kind, signature, complexity, ... — no callers/callees). #[serde(skip_serializing_if = "Option::is_none")] - pub symbol: Option, + pub symbol: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub callers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub callees: Option>, + /// Present only when `compact=true` truncated the callers list. + #[serde(skip_serializing_if = "Option::is_none")] + pub callers_truncated: Option, + /// Present only when `compact=true` truncated the callees list. + #[serde(skip_serializing_if = "Option::is_none")] + pub callees_truncated: Option, #[serde(skip_serializing_if = "Option::is_none")] pub used_fallback: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fallback_message: Option, + pub match_confidence: &'static str, +} + +/// Cap `items` to `max` entries in place, returning how many were dropped. +fn truncate_compact(items: &mut Vec, max: usize) -> Option { + if items.len() > max { + let dropped = items.len() - max; + items.truncate(max); + Some(dropped) + } else { + None + } } // ============================================================ @@ -125,6 +179,10 @@ pub(crate) async fn get_symbol_info( /// Get detailed symbol info: basic info + optional source + callers + callees. /// /// Returns `DetailedSymbolResult`. Shape matches the MCP codegraph_get_detailed_symbol response. +/// +/// `compact`, when true, caps `callers`/`callees` at 5 entries each (with +/// `callers_truncated`/`callees_truncated` reporting how many were dropped) — +/// for symbols with hundreds of callers this is the dominant cost driver. #[allow(clippy::too_many_arguments)] pub(crate) async fn get_detailed_symbol( graph: &RwLock, @@ -133,16 +191,19 @@ pub(crate) async fn get_detailed_symbol( include_source: bool, include_callers: bool, include_callees: bool, + compact: bool, used_fallback: bool, requested_line: Option, ) -> DetailedSymbolResult { - // Get basic symbol info - let (symbol, symbol_name) = if let Some(info) = query_engine.get_symbol_info(node_id).await { - let name = info.symbol.name.clone(); - (Some(info), name) - } else { - (None, String::new()) - }; + // Single depth-1 fetch of symbol info + callers/callees — reused for both + // the top-level `callers`/`callees` fields and the symbol metadata, so we + // don't run get_call_chain twice for the same data (see DetailedSymbolMeta). + let info = query_engine.get_symbol_info(node_id).await; + + let symbol_name = info + .as_ref() + .map(|i| i.symbol.name.clone()) + .unwrap_or_default(); let (used_fallback_field, fallback_message) = if used_fallback { ( @@ -165,26 +226,104 @@ pub(crate) async fn get_detailed_symbol( None }; - // Get callers if requested - let callers = if include_callers { - Some(query_engine.get_callers(node_id, 1).await) + const COMPACT_MAX_CALLS: usize = 5; + + let (mut callers, mut callees) = match &info { + Some(i) => (Some(i.callers.clone()), Some(i.callees.clone())), + None => (None, None), + }; + if !include_callers { + callers = None; + } + if !include_callees { + callees = None; + } + + let callers_truncated = if compact { + callers + .as_mut() + .and_then(|c| truncate_compact(c, COMPACT_MAX_CALLS)) } else { None }; - - // Get callees if requested - let callees = if include_callees { - Some(query_engine.get_callees(node_id, 1).await) + let callees_truncated = if compact { + callees + .as_mut() + .and_then(|c| truncate_compact(c, COMPACT_MAX_CALLS)) } else { None }; + let symbol = info.map(DetailedSymbolMeta::from); + DetailedSymbolResult { symbol, source, callers, callees, + callers_truncated, + callees_truncated, used_fallback: used_fallback_field, fallback_message, + match_confidence: crate::domain::node_resolution::match_confidence(used_fallback), + } +} + +#[cfg(test)] +mod tests { + use super::truncate_compact; + use crate::ai_query::{CallInfo, SymbolInfo, SymbolLocation}; + + fn dummy_call(n: u32) -> CallInfo { + CallInfo { + node_id: n as u64, + symbol: SymbolInfo { + name: format!("caller_{n}"), + kind: "function".to_string(), + location: SymbolLocation { + file: "a.rs".to_string(), + line: n, + column: 0, + end_line: n, + end_column: 0, + }, + signature: None, + docstring: None, + is_public: true, + visibility: "public".to_string(), + }, + call_site: SymbolLocation { + file: "a.rs".to_string(), + line: n, + column: 0, + end_line: n, + end_column: 0, + }, + depth: 1, + via_ops_struct: None, + ops_field: None, + } + } + + #[test] + fn truncate_compact_leaves_short_lists_untouched() { + let mut items = vec![dummy_call(1), dummy_call(2)]; + assert_eq!(truncate_compact(&mut items, 5), None); + assert_eq!(items.len(), 2); + } + + #[test] + fn truncate_compact_caps_and_reports_dropped_count() { + let mut items: Vec = (0..8).map(dummy_call).collect(); + let dropped = truncate_compact(&mut items, 5); + assert_eq!(dropped, Some(3)); + assert_eq!(items.len(), 5); + } + + #[test] + fn truncate_compact_exact_length_is_not_truncated() { + let mut items: Vec = (0..5).map(dummy_call).collect(); + assert_eq!(truncate_compact(&mut items, 5), None); + assert_eq!(items.len(), 5); } } diff --git a/crates/codegraph-server/src/handlers/ai_query.rs b/crates/codegraph-server/src/handlers/ai_query.rs index 9c1b745..0635327 100644 --- a/crates/codegraph-server/src/handlers/ai_query.rs +++ b/crates/codegraph-server/src/handlers/ai_query.rs @@ -719,6 +719,7 @@ impl CodeGraphBackend { include_source, include_callers, include_callees, + false, // compact: LSP callers don't yet expose this knob used_fallback, params.line, ) diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index cc8d0f6..6ab3e9b 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -2402,6 +2402,10 @@ impl McpServer { .or_else(|| args.get("include_callees")) .and_then(|v| v.as_bool()) .unwrap_or(true); + let compact = args + .get("compact") + .and_then(|v| v.as_bool()) + .unwrap_or(false); // Use fallback for uri+line, exact match for node_id let (target_node, used_fallback) = if let Some(id) = node_id { @@ -2423,6 +2427,7 @@ impl McpServer { include_source, include_callers, include_callees, + compact, used_fallback, line, ) diff --git a/crates/codegraph-server/src/mcp/tools.rs b/crates/codegraph-server/src/mcp/tools.rs index 004c042..bfeb29d 100644 --- a/crates/codegraph-server/src/mcp/tools.rs +++ b/crates/codegraph-server/src/mcp/tools.rs @@ -930,10 +930,19 @@ fn get_detailed_symbol_tool() -> Tool { "includeCallees".to_string(), boolean_prop("Include list of callees", true), ); + properties.insert( + "compact".to_string(), + boolean_prop( + "Cap callers/callees at 5 entries each (with a *_truncated count) — \ + use for symbols with large caller/callee lists to avoid paying for \ + hundreds of entries you won't read", + false, + ), + ); Tool { name: "codegraph_get_detailed_symbol".to_string(), - description: Some("Gets comprehensive symbol details including source code and relationships. USE WHEN: you need full context about a symbol — source code, callers, callees, complexity, and metadata together. MORE COMPLETE than get_symbol_info but heavier. Returns: symbol (name, kind, signature, visibility, uri, line_range, properties), source (full source code string), callers (array), callees (array). Toggle includeSource/includeCallers/includeCallees to control response size. Identify symbol via uri+line or nodeId.".to_string()), + description: Some("Gets comprehensive symbol details including source code and relationships. USE WHEN: you need full context about a symbol — source code, callers, callees, complexity, and metadata together. MORE COMPLETE than get_symbol_info but heavier. Returns: symbol (name, kind, signature, visibility, uri, line_range, properties — no duplicate callers/callees here, see top-level fields), source (full source code string), callers (array), callees (array). Toggle includeSource/includeCallers/includeCallees to control response size, or set compact=true to cap callers/callees at 5 entries each. Identify symbol via uri+line or nodeId.".to_string()), input_schema: ToolInputSchema { schema_type: "object".to_string(), properties: Some(properties), From 63f38b0467f6b8a6bf85ab8d740754b90cd5b010 Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:28:50 +0300 Subject: [PATCH 5/9] feat(mcp): make get_edit_context sections independently toggleable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_edit_context always computed all 5 sections (symbol/callers/tests/ memories/recentChanges) and the symbol's full source code, even when a caller only needed one or two of them. Add EditContextOptions (includeSymbol/includeCallers/includeTests/ includeMemories/includeRecentChanges, maxCallers, maxTests — defaults unchanged from prior behavior) and thread it through get_edit_context so disabling a section skips its underlying query entirely, not just its serialization — the compute savings matter as much as the token savings. EditContextSymbol.code becomes optional so includeSymbol=false can omit the single most expensive field while still returning name/type/location. --- .../src/domain/edit_context.rs | 74 +++++++++++++++---- crates/codegraph-server/src/mcp/server.rs | 44 +++++++++++ crates/codegraph-server/src/mcp/tools.rs | 35 ++++++++- 3 files changed, 138 insertions(+), 15 deletions(-) diff --git a/crates/codegraph-server/src/domain/edit_context.rs b/crates/codegraph-server/src/domain/edit_context.rs index ab7e93a..da05955 100644 --- a/crates/codegraph-server/src/domain/edit_context.rs +++ b/crates/codegraph-server/src/domain/edit_context.rs @@ -46,7 +46,11 @@ pub(crate) struct EditContextSymbol { pub name: String, #[serde(rename = "type")] pub symbol_type: String, - pub code: String, + /// Absent when `includeSymbol=false` was passed — name/type/location are + /// still returned (needed to orient the caller) but the source body, + /// the single most expensive field in this response, is skipped. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, pub language: String, pub location: EditContextLocation, } @@ -141,6 +145,35 @@ pub(crate) struct EditContextError { // Domain Function // ============================================================ +/// Which sections to assemble and how many list entries to allow per +/// section. Disabling a section skips its underlying queries entirely +/// (not just its serialization) — that's where the token *and* compute +/// savings come from. +#[derive(Debug, Clone, Copy)] +pub(crate) struct EditContextOptions { + pub include_symbol_source: bool, + pub include_callers: bool, + pub include_tests: bool, + pub include_memories: bool, + pub include_recent_changes: bool, + pub max_callers: usize, + pub max_tests: usize, +} + +impl Default for EditContextOptions { + fn default() -> Self { + EditContextOptions { + include_symbol_source: true, + include_callers: true, + include_tests: true, + include_memories: true, + include_recent_changes: true, + max_callers: 10, + max_tests: 5, + } + } +} + /// Assemble comprehensive edit context for a file + line in a single call. /// /// `file_path` should be the resolved filesystem path (not a URI). @@ -158,6 +191,7 @@ pub(crate) async fn get_edit_context( uri: &str, line: u32, max_tokens: usize, + options: EditContextOptions, ) -> Result { use crate::domain::node_resolution; @@ -223,12 +257,16 @@ pub(crate) async fn get_edit_context( }; // --- Section 1: Symbol source code (budget: up to 30%) --- - let source_code_str = { + let source_code_str = if options.include_symbol_source { let g = graph.read().await; - source_code::get_symbol_source(&g, target) - .unwrap_or_else(|| "".to_string()) + Some( + source_code::get_symbol_source(&g, target) + .unwrap_or_else(|| "".to_string()), + ) + } else { + None }; - let source_tokens = source_code_str.len() / 4; + let source_tokens = source_code_str.as_ref().map(|s| s.len() / 4).unwrap_or(0); let mut budget_remaining = max_tokens.saturating_sub(source_tokens); let symbol = EditContextSymbol { @@ -253,12 +291,14 @@ pub(crate) async fn get_edit_context( // --- Section 2: Callers (budget: up to 25% of original) --- let caller_budget = max_tokens / 4; - let callers: Vec = { + let callers: Vec = if !options.include_callers { + Vec::new() + } else { let callers = query_engine.get_callers(target, 1).await; let mut caller_tokens_used = 0usize; let mut caller_list = Vec::new(); - for caller in callers.iter().take(10) { + for caller in callers.iter().take(options.max_callers) { if caller_tokens_used >= caller_budget { break; } @@ -282,7 +322,9 @@ pub(crate) async fn get_edit_context( // --- Section 3: Related tests (budget: up to 20% of original) --- let test_budget = max_tokens / 5; - let tests: Vec = { + let tests: Vec = if !options.include_tests { + Vec::new() + } else { let mut test_list = Vec::new(); let mut test_tokens_used = 0usize; let mut seen_ids = std::collections::HashSet::::new(); @@ -293,7 +335,7 @@ pub(crate) async fn get_edit_context( let tests = query_engine.find_entry_points(&entry_types).await; for test in tests.iter().take(20) { - if test_list.len() >= 5 || test_tokens_used >= test_budget { + if test_list.len() >= options.max_tests || test_tokens_used >= test_budget { break; } let callees = query_engine.get_callees(test.node_id, 3).await; @@ -314,11 +356,11 @@ pub(crate) async fn get_edit_context( } // Stage 2: Same-file test functions (if room) - if test_list.len() < 5 { + if test_list.len() < options.max_tests { let g = graph.read().await; if let Ok(file_nodes) = g.query().property("path", sym_path.clone()).execute() { for node_id in file_nodes { - if test_list.len() >= 5 || test_tokens_used >= test_budget { + if test_list.len() >= options.max_tests || test_tokens_used >= test_budget { break; } if !seen_ids.insert(node_id) { @@ -345,7 +387,9 @@ pub(crate) async fn get_edit_context( }; // --- Section 4: Memories (budget: up to 15% of original) --- - let memories: Vec = { + let memories: Vec = if !options.include_memories { + Vec::new() + } else { let search_query = if sym_path.is_empty() { name.clone() } else { @@ -392,7 +436,9 @@ pub(crate) async fn get_edit_context( }; // --- Section 5: Recent git changes (budget: up to 10% of original) --- - let recent_changes: Vec = { + let recent_changes: Vec = if !options.include_recent_changes { + Vec::new() + } else { match workspace_folders.first().cloned() { Some(ws) => { let file_path_clone = sym_path.clone(); @@ -452,7 +498,7 @@ pub(crate) async fn get_edit_context( }; // Capture section presence booleans before moving the Vecs. - let has_symbol = !source_code_str.is_empty(); + let has_symbol = source_code_str.is_some(); let has_callers = !callers.is_empty(); let has_tests = !tests.is_empty(); let has_memories = !memories.is_empty(); diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index 6ab3e9b..609cb3f 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -2785,6 +2785,49 @@ impl McpServer { .map(|v| v as usize) .unwrap_or(8000); + let defaults = crate::domain::edit_context::EditContextOptions::default(); + let bool_arg = |key: &str, snake: &str, default: bool| { + args.get(key) + .or_else(|| args.get(snake)) + .and_then(|v| v.as_bool()) + .unwrap_or(default) + }; + let options = crate::domain::edit_context::EditContextOptions { + include_symbol_source: bool_arg( + "includeSymbol", + "include_symbol", + defaults.include_symbol_source, + ), + include_callers: bool_arg( + "includeCallers", + "include_callers", + defaults.include_callers, + ), + include_tests: bool_arg("includeTests", "include_tests", defaults.include_tests), + include_memories: bool_arg( + "includeMemories", + "include_memories", + defaults.include_memories, + ), + include_recent_changes: bool_arg( + "includeRecentChanges", + "include_recent_changes", + defaults.include_recent_changes, + ), + max_callers: args + .get("maxCallers") + .or_else(|| args.get("max_callers")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(defaults.max_callers), + max_tests: args + .get("maxTests") + .or_else(|| args.get("max_tests")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(defaults.max_tests), + }; + let file_path = crate::domain::node_resolution::resolve_uri_to_path(uri) .ok_or("Invalid URI")?; let result = crate::domain::edit_context::get_edit_context( @@ -2796,6 +2839,7 @@ impl McpServer { uri, line, max_tokens, + options, ) .await; Ok(match result { diff --git a/crates/codegraph-server/src/mcp/tools.rs b/crates/codegraph-server/src/mcp/tools.rs index bfeb29d..1b242f1 100644 --- a/crates/codegraph-server/src/mcp/tools.rs +++ b/crates/codegraph-server/src/mcp/tools.rs @@ -403,10 +403,43 @@ fn get_edit_context_tool() -> Tool { Some(8000.0), ), ); + properties.insert( + "includeSymbol".to_string(), + boolean_prop( + "Include the target symbol's source code (name/type/location are always \ + returned regardless). Disable when you already have the source and \ + only need callers/tests/memories/git history.", + true, + ), + ); + properties.insert( + "includeCallers".to_string(), + boolean_prop("Include the callers section", true), + ); + properties.insert( + "includeTests".to_string(), + boolean_prop("Include the related-tests section", true), + ); + properties.insert( + "includeMemories".to_string(), + boolean_prop("Include the memories section", true), + ); + properties.insert( + "includeRecentChanges".to_string(), + boolean_prop("Include the recent git changes section", true), + ); + properties.insert( + "maxCallers".to_string(), + number_prop("Maximum number of callers to include (default: 10)", Some(10.0)), + ); + properties.insert( + "maxTests".to_string(), + number_prop("Maximum number of related tests to include (default: 5)", Some(5.0)), + ); Tool { name: "codegraph_get_edit_context".to_string(), - description: Some("Assembles everything needed to edit code at a specific location in a single call. USE WHEN: you are about to modify, refactor, or fix code and need full context before making changes. PREFER THIS over codegraph_get_ai_context when you are about to write or modify code — it includes callers (impact), tests (what to update), and git history (recent context) that get_ai_context does not. Use get_ai_context instead when you only need to understand or explain code. Returns 5 sections: (1) symbol — full source code of the function/method at the given line, (2) callers — functions that call this symbol (to assess impact of changes), (3) tests — related test functions (to know what to update/run), (4) memories — relevant debug notes, architectural decisions, and known issues, (5) recentChanges — recent git commits that touched this file. EXAMPLE: Before modifying a function's signature, call this to see all callers that would break, tests that need updating, and whether someone recently changed this code. Token budget controls total context size with priority: symbol > callers > tests > memories > git history. Requires uri and line parameters.".to_string()), + description: Some("Assembles everything needed to edit code at a specific location in a single call. USE WHEN: you are about to modify, refactor, or fix code and need full context before making changes. PREFER THIS over codegraph_get_ai_context when you are about to write or modify code — it includes callers (impact), tests (what to update), and git history (recent context) that get_ai_context does not. Use get_ai_context instead when you only need to understand or explain code. Returns up to 5 sections, each independently toggleable via includeSymbol/includeCallers/includeTests/includeMemories/includeRecentChanges (skipping a section skips its underlying queries too, not just its output — the cheapest way to shrink this call): (1) symbol — source code of the function/method at the given line, (2) callers — functions that call this symbol (to assess impact of changes, capped via maxCallers), (3) tests — related test functions (to know what to update/run, capped via maxTests), (4) memories — relevant debug notes, architectural decisions, and known issues, (5) recentChanges — recent git commits that touched this file. EXAMPLE: Before modifying a function's signature, call this to see all callers that would break, tests that need updating, and whether someone recently changed this code. Token budget controls total context size with priority: symbol > callers > tests > memories > git history. Requires uri and line parameters.".to_string()), input_schema: ToolInputSchema { schema_type: "object".to_string(), properties: Some(properties), From 55096f0de3ba50e2a068c420388acc33508ca87e Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:30:55 +0300 Subject: [PATCH 6/9] feat(mcp): add codegraph_index_health diagnostics tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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. --- crates/codegraph-server/src/mcp/server.rs | 97 ++++++++++++++++++++++- crates/codegraph-server/src/mcp/tools.rs | 21 ++++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index 609cb3f..de29b93 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -3478,7 +3478,7 @@ impl McpServer { ) }; - Ok(serde_json::json!({ + let mut response = serde_json::json!({ "status": status, "message": message, "files_indexed": total, @@ -3486,6 +3486,101 @@ impl McpServer { "files_skipped": total - parsed, "node_count_after": node_count_after, "auto_retried_with_force": auto_retried + }); + if degraded { + if let Some(obj) = response.as_object_mut() { + obj.insert( + "suggested_next_queries".to_string(), + serde_json::json!([ + "codegraph_index_health — inspect workspace_root, .codegraphignore \ + exclusions, and files_changed_since_index before retrying", + ]), + ); + } + } + Ok(response) + } + + // ==================== Index Health ==================== + "codegraph_index_health" => { + let (node_count, edge_count) = { + let graph = self.backend.graph.read().await; + (graph.node_count(), graph.edge_count()) + }; + + let workspace_root = self + .backend + .workspace_folders + .first() + .map(|p| p.display().to_string()); + + let workspace_revision_hint = self + .backend + .workspace_folders + .first() + .and_then(|ws| crate::git_mining::GitExecutor::new(ws).ok()) + .and_then(|g| g.head_commit().ok()) + .map(|h| h[..8.min(h.len())].to_string()); + + // Cheap staleness check: re-hash only files we already know + // about (no directory walk) and count content drift. Doesn't + // catch brand-new/deleted files — that needs a full walk, + // which is what reindex itself does; this stays cheap on + // purpose so it's safe to call before every risky operation. + let files_changed_since_index = { + let state = self.backend.index_state.lock().await; + state + .all_hashes() + .iter() + .filter(|(path, &stored_hash)| { + match std::fs::read(path) { + Ok(content) => { + crate::indexer::Indexer::hash_content(&content) != stored_hash + } + Err(_) => true, // file removed/unreadable since indexing + } + }) + .count() + }; + let files_tracked = self.backend.index_state.lock().await.len(); + + let registry = McpBackend::list_indexed_projects().unwrap_or_default(); + let (index_generated_at, other_namespaces): (Option, Vec) = { + let mut generated_at = None; + let mut others = Vec::new(); + for project in registry { + let slug = project.get("slug").and_then(|v| v.as_str()).unwrap_or(""); + if slug == self.backend.project_slug { + generated_at = project.get("last_indexed").and_then(|v| v.as_u64()); + } else { + others.push(project); + } + } + (generated_at, others) + }; + + let potentially_stale = node_count == 0 || files_changed_since_index > 0; + + let mut suggested_next_queries: Vec<&str> = Vec::new(); + if node_count == 0 { + suggested_next_queries.push("codegraph_reindex_workspace(force=true)"); + } else if files_changed_since_index > 0 { + suggested_next_queries.push("codegraph_reindex_workspace(force=false)"); + } + + Ok(serde_json::json!({ + "namespace": self.backend.project_slug, + "workspace_root": workspace_root, + "node_count": node_count, + "edge_count": edge_count, + "generation": crate::memory::graph_db_generation(), + "index_generated_at": index_generated_at, + "workspace_revision_hint": workspace_revision_hint, + "files_tracked": files_tracked, + "files_changed_since_index": files_changed_since_index, + "potentially_stale": potentially_stale, + "other_namespaces": other_namespaces, + "suggested_next_queries": suggested_next_queries, })) } diff --git a/crates/codegraph-server/src/mcp/tools.rs b/crates/codegraph-server/src/mcp/tools.rs index 1b242f1..46d4aee 100644 --- a/crates/codegraph-server/src/mcp/tools.rs +++ b/crates/codegraph-server/src/mcp/tools.rs @@ -142,10 +142,11 @@ pub fn get_all_tools() -> Vec { find_dead_imports_tool(), // Ops Struct Tools (1) find_implementors_tool(), - // Admin Tools (3) + // Admin Tools (4) reindex_workspace_tool(), index_files_tool(), index_directory_tool(), + index_health_tool(), // PR / Change Analysis (1) pr_context_tool(), // Docs Tools (7) @@ -1236,6 +1237,18 @@ fn reindex_workspace_tool() -> Tool { } } +fn index_health_tool() -> Tool { + Tool { + name: "codegraph_index_health".to_string(), + 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()), + input_schema: ToolInputSchema { + schema_type: "object".to_string(), + properties: None, + required: None, + }, + } +} + fn index_files_tool() -> Tool { let mut properties = HashMap::new(); properties.insert( @@ -1801,14 +1814,14 @@ mod tests { fn test_get_all_tools_count() { let tools = get_all_tools(); // Analysis: 12 (incl. probe_symbol), Search: 8, Navigation: 3, Memory: 7, - // Dead Imports: 1, Ops: 1, Admin: 3, Docs: 7, PR: 1 = 43 community tools + // Dead Imports: 1, Ops: 1, Admin: 4 (incl. index_health), Docs: 7, PR: 1 = 44 community tools // (12 premium tools moved to pro edition: scan_security, analyze_coupling, find_unused_code, // find_duplicates, find_similar, cluster_symbols, compare_symbols, cross_project_search, // mine_git_history, mine_git_history_for_file, search_git_history) assert_eq!( tools.len(), - 43, - "Expected 43 community tools, got {}", + 44, + "Expected 44 community tools, got {}", tools.len() ); } From f9bcaccd873bcc8b4dd3f2b57d63c1561139af92 Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:31:57 +0300 Subject: [PATCH 7/9] fix(python): resolve self./cls. calls into local Calls edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent bugs meant a call from inside one method to another via self./cls. — the majority of calls inside any Python class — never became a Calls edge in the graph: - ir_to_graph registered functions in node_map only under their bare name, but extract_class extracts calls with a qualified caller name ("ClassName.method_name"). Looking up the caller always missed, so the call fell through to cross-file unresolved-call resolution instead of a same-file edge. - extract_callee_name returned the full attribute text ("self.helper") for a `self.helper()` call instead of the bare method name, which couldn't match anything in node_map either. Register methods under both the bare and qualified name, and strip self./cls. from the callee name before matching. Also fixes Contains (class -> method) edges incorrectly linking same-named methods across two different classes in one file, which used the same bare-name lookup. get_callers/get_callees/get_call_graph/analyze_impact on methods were systematically undercounting call relationships unless the callee happened to also be found by the cross-file fallback. --- crates/codegraph-python/src/extractor.rs | 24 +++++++- crates/codegraph-python/src/parser_impl.rs | 67 +++++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/crates/codegraph-python/src/extractor.rs b/crates/codegraph-python/src/extractor.rs index 4733abc..f58e3c6 100644 --- a/crates/codegraph-python/src/extractor.rs +++ b/crates/codegraph-python/src/extractor.rs @@ -604,12 +604,32 @@ fn extract_calls_recursive( } } -/// Extract the callee name from a call's function node +/// Extract the callee name from a call's function node. +/// +/// For `self.method()`/`cls.method()` — by far the most common call shape +/// inside any method — returns the bare `method` name, not `self.method`. +/// `self`/`cls` are Python's instance/class-reference convention, not a +/// real qualifier; graph nodes are keyed by bare name (or `Class.method` +/// for the *caller* side, see `parser_impl::ir_to_graph`), so a literal +/// `"self.method"` string never matched anything and every such call was +/// silently dropped instead of becoming a same-file Calls edge. Other +/// `obj.method()` shapes keep the full dotted text — resolving those +/// would need type inference this extractor doesn't do, so falling back +/// to unresolved (as before) is still correct there. fn extract_callee_name(source: &[u8], node: Node) -> String { match node.kind() { "identifier" => node.utf8_text(source).unwrap_or("").to_string(), "attribute" => { - // Handle obj.method() or self.method() + let object_is_self_or_cls = node + .child_by_field_name("object") + .and_then(|o| o.utf8_text(source).ok()) + .is_some_and(|t| t == "self" || t == "cls"); + if object_is_self_or_cls { + if let Some(attr) = node.child_by_field_name("attribute") { + return attr.utf8_text(source).unwrap_or("").to_string(); + } + } + // Handle obj.method() — kept as the full dotted text (unresolved). node.utf8_text(source).unwrap_or("").to_string() } _ => String::new(), diff --git a/crates/codegraph-python/src/parser_impl.rs b/crates/codegraph-python/src/parser_impl.rs index 7980978..0c00592 100644 --- a/crates/codegraph-python/src/parser_impl.rs +++ b/crates/codegraph-python/src/parser_impl.rs @@ -155,6 +155,16 @@ impl PythonParser { .map_err(|e| ParserError::GraphError(e.to_string()))?; node_map.insert(func.name.clone(), func_id); + // Methods are extracted with a qualified caller name + // ("ClassName.method_name", see extractor.rs) for their own call + // sites, but were only ever registered here under the bare name + // — so `node_map.get(&call.caller)` below always missed for + // calls originating inside a method, and every such call was + // silently dropped into `unresolved_calls` instead of becoming + // a same-file Calls edge. Register the qualified name too. + if let Some(parent) = &func.parent_class { + node_map.insert(format!("{parent}.{}", func.name), func_id); + } function_ids.push(func_id); // Link function to file @@ -193,10 +203,16 @@ impl PythonParser { .map_err(|e| ParserError::GraphError(e.to_string()))?; // Methods are already added via ir.functions with parent_class set - // Just create edges from class to its methods + // Just create edges from class to its methods. Prefer the + // qualified key — two classes with a same-named method (e.g. + // `__init__`) would otherwise both resolve to whichever one + // `node_map`'s bare-name entry happened to be last written by. for method in &class.methods { - let method_name = method.name.clone(); - if let Some(&method_id) = node_map.get(&method_name) { + let qualified = format!("{}.{}", class.name, method.name); + let method_id = node_map + .get(&qualified) + .or_else(|| node_map.get(&method.name)); + if let Some(&method_id) = method_id { // Link method to class graph .add_edge(class_id, method_id, EdgeType::Contains, PropertyMap::new()) @@ -588,6 +604,51 @@ mod tests { assert_eq!(metrics.files_failed, 0); } + #[test] + fn test_call_from_inside_method_creates_local_calls_edge() { + use codegraph::EdgeType; + + // Regression test: calls made from inside a class method were never + // wired as Calls edges because the extractor emits a qualified + // caller name ("Foo.caller_method") for method-body calls, but + // `node_map` only ever registered functions under their bare name. + // `get_callers`/`get_callees` on any method silently returned + // nothing for same-file calls. + let source = "\ +class Foo: + def helper(self): + return 1 + + def caller_method(self): + return self.helper() +"; + let parser = PythonParser::new(); + let mut graph = codegraph::CodeGraph::in_memory().unwrap(); + let file_info = parser + .parse_source(source, Path::new("test.py"), &mut graph) + .unwrap(); + + let node_named = |name: &str| { + file_info + .functions + .iter() + .copied() + .find(|&id| graph.get_node(id).unwrap().properties.get_string("name") == Some(name)) + .unwrap_or_else(|| panic!("no function node named {name}")) + }; + + let helper_id = node_named("helper"); + let caller_id = node_named("caller_method"); + + let edges = graph.get_edges_between(caller_id, helper_id).unwrap(); + assert!( + edges + .iter() + .any(|&eid| graph.get_edge(eid).unwrap().edge_type == EdgeType::Calls), + "expected a Calls edge from caller_method to helper" + ); + } + #[test] fn test_implements_edge_creation() { use codegraph::{CodeGraph, EdgeType}; From 3e903be389e678f88b83ac03a18f759ceb1293d4 Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 19:32:04 +0300 Subject: [PATCH 8/9] docs: add MCP fix/improvement report Summarizes the resolver, MCP tool, and Python call-graph fixes on this branch: what was broken, what changed, and the measured effect of each. --- MCP_FIX_REPORT.md | 213 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 MCP_FIX_REPORT.md diff --git a/MCP_FIX_REPORT.md b/MCP_FIX_REPORT.md new file mode 100644 index 0000000..5ac427e --- /dev/null +++ b/MCP_FIX_REPORT.md @@ -0,0 +1,213 @@ +# Отчёт: правки MCP-инструментов и резолвера символов + +Период: 2026-07-08 — 2026-07-09. + +Свод изменений, сделанных в рамках диагностики и доработки MCP-сервера: +исправление ошибок в резолюции символов и call-графа, повышение +достоверности ответов инструментов, снижение расхода токенов. + +## 1. Memory-gate: корректная обработка `available_memory == 0` + +**Файлы:** `memory.rs`, `mcp/engine.rs`. + +- **Было:** проверка «хватает ли памяти для загрузки ONNX-модели + эмбеддингов» была продублирована в двух местах с одинаковым порогом + `1_500_000_000` байт. Показание `available_memory == 0` (что `sysinfo` + реально отдаёт под memory pressure) трактовалось как «памяти нет», и + сервер гарантированно уходил в graph-only режим без семантического + поиска. +- **Стало:** логика вынесена в `should_skip_embeddings_for_available_memory`, + используется из обоих мест. `0` теперь трактуется как «показание + неизвестно», а не «памяти нет» — сервер пробует загрузить модель вместо + автоматического отказа. +- **Эффект:** на машине с нестабильными показаниями `sysinfo` (382–482 MB, + иногда ровно 0) сервер стал грузить модель эмбеддингов вместо + гарантированной деградации до graph-only. + +## 2. `nodeId` как число ломал резолюцию узла + +**Файл:** `mcp/server.rs`. + +- **Было:** `args.get("nodeId").and_then(|v| v.as_str())` — если клиент + передавал ID числом (`{"nodeId": 1306}`, ровно так, как его отдаёт + `codegraph_symbol_search`), парсинг молча проваливался в `None`, и + инструмент отвечал «узел не найден», хотя он существовал в графе. +- **Стало:** добавлена `arg_node_id(v) -> Option`, принимающая и + число, и строку. Применена во всех 5 местах (`get_callers`, + `get_callees`, `traverse_graph`, `get_symbol_info`, + `get_detailed_symbol`). +- **Эффект:** инструменты, принимающие `nodeId`, теперь работают + независимо от того, в каком JSON-типе клиент передал идентификатор. + +## 3. Рассинхронизация форматов путей (относительный vs `file://`) + +**Файлы:** `node_resolution.rs`, `mcp/server.rs`, `handlers/ai_context.rs`. + +- **Было:** индексатор хранит путь относительно workspace-root + (`./crates/foo/bar.rs`), а обработчики требовали `file://` URI, + превращали его в абсолютный путь и сравнивали через `==` — строки + никогда не совпадали. Наихудший случай: при ошибке парсинга URI путь + молча превращался в пустую строку, которая случайно совпадала с path + нескольких синтетических узлов — инструмент возвращал **случайный, + не относящийся к запросу символ** без явной ошибки. +- **Стало:** добавлены `paths_match()` (сравнение путей по границе + компонента, устойчивое к relative/absolute; пустые строки не совпадают + ни с чем) и `resolve_uri_to_path()` (принимает и `file://`, и обычный + путь). Применены во всех местах, где раньше было строгое `==` по path. +- **Эффект:** `get_ai_context`, `get_edit_context`, `get_callers`, + `get_callees`, `traverse_graph`, `get_symbol_info`, `get_detailed_symbol`, + `find_related_tests`, `analyze_complexity` перестали как не находить + проиндексированные файлы, так и молча подменять символ на неверный. + +## 4. `codegraph_reindex_workspace`: ложный `status: "success"` на пустом графе + +**Файл:** `mcp/server.rs`. + +- **Было:** мягкий (`force=false`) реиндекс мог отрапортовать успех, даже + если граф после реиндекса фактически пуст. +- **Стало:** после индексации сверяется `node_count` с количеством + обработанных файлов; при явном расхождении статус — `degraded`. + Дополнительно: если мягкий реиндекс дал `degraded`, инструмент + автоматически повторяет его с `force=true` один раз (поле + `auto_retried_with_force`), прежде чем возвращать ответ клиенту. +- **Эффект:** типовой случай рассинхронизации инкрементального состояния + теперь устраняется без ручного повторного вызова с `force=true`. + +## 5. Структурированные причины «symbol not found» + +**Файлы:** `node_resolution.rs`, `mcp/server.rs`. + +- **Было:** все инструменты при неудачной резолюции возвращали одну и ту + же общую фразу без указания причины (неверный URI? неиндексированный + файл? пустой workspace?). +- **Стало:** `enum SymbolNotFoundReason` (`InvalidUri`, `WorkspaceNotIndexed`, + `FileNotIndexed`) с кодом и actionable-подсказкой на каждый случай, + плюс машиночитаемый `suggested_next_queries()` — подключены во всех + 8 инструментах, резолвящих `uri+line`/`nodeId`. +- **Эффект:** агент получает конкретное объяснение причины отказа и + готовую подсказку, что вызвать дальше, вместо непрозрачной общей + ошибки. + +## 6. `match_confidence` в ответах uri+line-инструментов + +**Файлы:** `node_resolution.rs`, `callers.rs`, `symbol_info.rs`, +`call_graph.rs`, `impact.rs`. + +- **Было:** уровень доверия к резолюции виден только опционально + (`used_fallback`, поле присутствует лишь при `true`). +- **Стало:** добавлено поле `match_confidence: "exact" | "fallback"`, + присутствующее в ответе всегда. +- **Эффект:** единообразная, всегда доступная проверка достоверности + ответа без реконструкции состояния из опционального поля. + +## 7. `codegraph_probe_symbol` — новый лёгкий инструмент + +**Файлы:** `domain/probe_symbol.rs` (новый), `mcp/server.rs`, `mcp/tools.rs`. + +- **Задача:** проверить «попал ли запрос в правильный символ» без + full-context ответа. +- **Реализация:** принимает `uri+line`/`nodeId`, возвращает только + `name`/`type`/`node_id`/`uri`/`line_start`/`line_end`/`used_fallback`/ + `match_confidence` — без source, без callers/callees. +- **Эффект:** на порядок более дешёвая по токенам проверка резолюции по + сравнению с полным `get_symbol_info`. + +## 8. `get_detailed_symbol`: дублирование callers/callees + `compact` + +**Файлы:** `symbol_info.rs`, `mcp/server.rs`, `mcp/tools.rs`, +`handlers/ai_query.rs`. + +- **Было:** `get_detailed_symbol` считал callers/callees дважды — один раз + внутри `get_symbol_info` (вложенно в `symbol.callers`/`symbol.callees`), + второй раз отдельным вызовом `get_callers`/`get_callees` с теми же + аргументами — итоговый JSON содержал один и тот же список каждого + caller'а дважды, плюс лишний повторный обход графа на сервере. +- **Стало:** `DetailedSymbolMeta` — вариант без вложенных callers/callees; + top-level `callers`/`callees` берутся из уже посчитанного результата. + Добавлен opt-in `compact: bool` (default `false`) — обрезает списки до + 5 записей с полями `*_truncated`. +- **Эффект:** устранено дублирование данных в ответе и лишний обход + графа; для символов с большим числом caller'ов доступен компактный + режим ответа. + +## 9. Секционный `get_edit_context` + +**Файлы:** `edit_context.rs`, `mcp/server.rs`, `mcp/tools.rs`. + +- **Было:** все 5 секций (`symbol`/`callers`/`tests`/`memories`/ + `recentChanges`), включая дорогое поле `code`, вычислялись безусловно. +- **Стало:** добавлены переключатели `includeSymbol`/`includeCallers`/ + `includeTests`/`includeMemories`/`includeRecentChanges` и лимиты + `maxCallers`/`maxTests` — секции пропускаются **на этапе вычисления**, + а не только сериализации. По умолчанию поведение не изменилось. +- **Эффект:** клиент может запросить только нужные секции и снизить + нагрузку на сервер, а не только объём ответа. + +## 10. `find_nearest_node` резолвился в `CodeFile`-узел вместо символа + +**Файл:** `node_resolution.rs`. + +- **Было:** узел файла (`NodeType::CodeFile`) создаётся без + `line_start`/`line_end`; чтение через не-`Option` геттеры молча + подставляло `0`, что давало диапазон `0..0`. При запросе с + `target_line == 0` (частый дефолт, если клиент не указал строку) такой + узел формально «выигрывал» у любой реальной функции/класса как + «самое тесное совпадение» (`range_size = 0`) — резолвер возвращал файл + вместо символа, без пометки fallback, с `code: ""` + и пустыми callers/tests. +- **Стало:** `find_nearest_node` использует `line_start_opt`/`line_end_opt` + и пропускает узлы без явно проставленного диапазона строк. Если в файле + нет ни одного символа с реальным диапазоном, резолвер честно возвращает + `None` вместо фейкового совпадения на файл. +- **Эффект:** запрос с `line: 0` (или любой строкой вне размеченных + символов) теперь резолвится в ближайший реальный символ файла, а не в + бесполезный узел файла. + +## 11. Вызовы `self.method()`/`cls.method()` не попадали в call-граф (Python) + +**Файлы:** `codegraph-python/src/extractor.rs`, +`codegraph-python/src/parser_impl.rs`. + +- **Было:** два независимых дефекта одновременно исключали из графа + подавляющее большинство вызовов внутри Python-класса: + 1. `node_map` при добавлении методов регистрировался только под голым + именем (`"method_name"`), а вызовы извлекались с квалифицированным + именем caller'а (`"ClassName.method_name"`) — поиск `caller` в + `node_map` всегда проваливался, вызов уходил в межфайловое + разрешение вместо локального ребра. + 2. `extract_callee_name` для `self.helper()` возвращала весь текст узла + (`"self.helper"`) вместо голого имени (`"helper"`) — такая строка не + совпадала ни с одной записью `node_map`. +- **Стало:** методы регистрируются в `node_map` дополнительно под + квалифицированным ключом; `extract_callee_name` для `self.`/`cls.` + возвращает голое имя метода. Заодно исправлено связывание + `Contains`-рёбер класс→метод при одноимённых методах в разных классах + одного файла (раньше могли связаться с чужим методом). +- **Эффект:** вызовы вида `self.method()`/`cls.method()` — то есть + большинство вызовов внутри Python-класса — теперь становятся + `Calls`-рёбрами в графе; `get_callers`/`get_callees`/`get_call_graph`/ + `analyze_impact` на методах перестали систематически недооценивать + связи. + +## 12. `codegraph_index_health` — новый инструмент диагностики индекса + +**Файлы:** `mcp/server.rs`, `mcp/tools.rs`. + +- **Задача:** дать агенту дешёвый способ проверить, не устарел ли индекс, + и увидеть состояние всех известных namespace одним вызовом (вместо + нескольких MCP-сессий). +- **Реализация:** переиспользует уже существующую инфраструктуру + (`_registry:` записи в общей `graph.db`, `graph_db_generation()`, + `GitExecutor::head_commit()`, `IndexState::all_hashes()`). Отдаёт + текущее состояние графа, generation, git HEAD, число изменившихся + файлов с момента индексации, список других namespace и + `suggested_next_queries` при обнаруженном дрейфе. +- **Эффект:** агент может проверить актуальность индекса и увидеть все + проиндексированные проекты без дополнительных round-trip'ов. + +## Проверка + +- `cargo test -p codegraph-server` — 369 unit-тестов, 0 упавших. +- `cargo test -p codegraph-python` — 35 unit-тестов, 0 упавших. +- Живые прогоны через `--run-tool` на реальном многофайловом workspace + подтвердили каждый фикс отдельно (см. историю коммитов для деталей). From 488133518cdbc967313b0c891f8b1cfde6956649 Mon Sep 17 00:00:00 2001 From: nekrasov Date: Thu, 9 Jul 2026 21:38:09 +0300 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=D1=81=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=8C=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=20MCP-=D1=84=D0=B8=D0=BA=D1=81=D0=B0=D0=BC=20=D0=B2=20CH?= =?UTF-8?q?ANGELOG,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B2=20=D0=B2=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалён временный MCP_FIX_REPORT.md — его содержимое перенесено в раздел Unreleased CHANGELOG.md без выпуска новой версии. --- CHANGELOG.md | 53 ++++++++++++ MCP_FIX_REPORT.md | 213 ---------------------------------------------- README.md | 7 ++ 3 files changed, 60 insertions(+), 213 deletions(-) delete mode 100644 MCP_FIX_REPORT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c591086..2ae6d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,59 @@ All notable changes to CodeGraph are documented here. Versions follow [Semantic Versioning](https://semver.org/). Each release is tagged as `vscode/vX.Y.Z` in the git history. +## [Unreleased] + +### MCP Server Fixes + +Symbol resolution and call-graph accuracy fixes, plus lower token cost on +existing tools. + +- **Memory-gate fix** — `available_memory == 0` (a real `sysinfo` reading + under memory pressure) was treated as "no memory available" and forced + graph-only mode. Now treated as "unknown" so the embedding model still + loads. +- **`nodeId` as a number was silently rejected** — tools required + `nodeId` as a string, but `codegraph_symbol_search` returns it as a + number; passing it straight back caused false "node not found" errors. + Fixed across `get_callers`, `get_callees`, `traverse_graph`, + `get_symbol_info`, `get_detailed_symbol`. +- **Relative vs `file://` path mismatch** — path comparisons used strict + `==` between workspace-relative and `file://`-resolved paths, so they + never matched; a URI-parse failure could even resolve to an unrelated + symbol silently. Fixed with boundary-aware path matching across all + `uri`-resolving tools. +- **`codegraph_reindex_workspace` false success** — a soft reindex could + report `success` on an empty graph; now reports `degraded` and + auto-retries once with `force=true`. +- **Structured "symbol not found" reasons** — replaced a single generic + error with `InvalidUri` / `WorkspaceNotIndexed` / `FileNotIndexed`, + each with an actionable hint and suggested next query. +- **`match_confidence` field** — `"exact" | "fallback"` now always present + on `uri+line`-resolving tool responses (previously only present as an + optional `used_fallback` flag). +- **`codegraph_probe_symbol`** (new) — lightweight resolution check + (name/type/node_id/uri/line range/confidence only, no source or + callers/callees) for verifying a query hit the right symbol cheaply. +- **`get_detailed_symbol` deduplication** — callers/callees were computed + and serialized twice (once nested, once top-level); now computed once. + Added opt-in `compact: bool` to truncate long lists. +- **`get_edit_context` section toggles** — added + `includeSymbol`/`includeCallers`/`includeTests`/`includeMemories`/ + `includeRecentChanges` and `maxCallers`/`maxTests`, skipped at + computation time rather than only at serialization. Default behavior + unchanged. +- **`find_nearest_node` file-node fallback** — a file node with no + `line_start`/`line_end` could "win" as nearest match for `line: 0` + queries, returning the file instead of a symbol. Now skips nodes + without an explicit line range. +- **Python `self.`/`cls.` calls missing from the call graph** — method + calls inside a class weren't registered as `Calls` edges due to a + node-map key mismatch and an unstripped callee name; fixed, along with + `Contains` edge mislinking for same-named methods in different classes. +- **`codegraph_index_health`** (new) — diagnostics tool reporting graph + state, generation, git HEAD, file drift since last index, and other + known namespaces in one call. + ## [0.17.0] — 2026-05-25 ### Documentation Intelligence (new) diff --git a/MCP_FIX_REPORT.md b/MCP_FIX_REPORT.md deleted file mode 100644 index 5ac427e..0000000 --- a/MCP_FIX_REPORT.md +++ /dev/null @@ -1,213 +0,0 @@ -# Отчёт: правки MCP-инструментов и резолвера символов - -Период: 2026-07-08 — 2026-07-09. - -Свод изменений, сделанных в рамках диагностики и доработки MCP-сервера: -исправление ошибок в резолюции символов и call-графа, повышение -достоверности ответов инструментов, снижение расхода токенов. - -## 1. Memory-gate: корректная обработка `available_memory == 0` - -**Файлы:** `memory.rs`, `mcp/engine.rs`. - -- **Было:** проверка «хватает ли памяти для загрузки ONNX-модели - эмбеддингов» была продублирована в двух местах с одинаковым порогом - `1_500_000_000` байт. Показание `available_memory == 0` (что `sysinfo` - реально отдаёт под memory pressure) трактовалось как «памяти нет», и - сервер гарантированно уходил в graph-only режим без семантического - поиска. -- **Стало:** логика вынесена в `should_skip_embeddings_for_available_memory`, - используется из обоих мест. `0` теперь трактуется как «показание - неизвестно», а не «памяти нет» — сервер пробует загрузить модель вместо - автоматического отказа. -- **Эффект:** на машине с нестабильными показаниями `sysinfo` (382–482 MB, - иногда ровно 0) сервер стал грузить модель эмбеддингов вместо - гарантированной деградации до graph-only. - -## 2. `nodeId` как число ломал резолюцию узла - -**Файл:** `mcp/server.rs`. - -- **Было:** `args.get("nodeId").and_then(|v| v.as_str())` — если клиент - передавал ID числом (`{"nodeId": 1306}`, ровно так, как его отдаёт - `codegraph_symbol_search`), парсинг молча проваливался в `None`, и - инструмент отвечал «узел не найден», хотя он существовал в графе. -- **Стало:** добавлена `arg_node_id(v) -> Option`, принимающая и - число, и строку. Применена во всех 5 местах (`get_callers`, - `get_callees`, `traverse_graph`, `get_symbol_info`, - `get_detailed_symbol`). -- **Эффект:** инструменты, принимающие `nodeId`, теперь работают - независимо от того, в каком JSON-типе клиент передал идентификатор. - -## 3. Рассинхронизация форматов путей (относительный vs `file://`) - -**Файлы:** `node_resolution.rs`, `mcp/server.rs`, `handlers/ai_context.rs`. - -- **Было:** индексатор хранит путь относительно workspace-root - (`./crates/foo/bar.rs`), а обработчики требовали `file://` URI, - превращали его в абсолютный путь и сравнивали через `==` — строки - никогда не совпадали. Наихудший случай: при ошибке парсинга URI путь - молча превращался в пустую строку, которая случайно совпадала с path - нескольких синтетических узлов — инструмент возвращал **случайный, - не относящийся к запросу символ** без явной ошибки. -- **Стало:** добавлены `paths_match()` (сравнение путей по границе - компонента, устойчивое к relative/absolute; пустые строки не совпадают - ни с чем) и `resolve_uri_to_path()` (принимает и `file://`, и обычный - путь). Применены во всех местах, где раньше было строгое `==` по path. -- **Эффект:** `get_ai_context`, `get_edit_context`, `get_callers`, - `get_callees`, `traverse_graph`, `get_symbol_info`, `get_detailed_symbol`, - `find_related_tests`, `analyze_complexity` перестали как не находить - проиндексированные файлы, так и молча подменять символ на неверный. - -## 4. `codegraph_reindex_workspace`: ложный `status: "success"` на пустом графе - -**Файл:** `mcp/server.rs`. - -- **Было:** мягкий (`force=false`) реиндекс мог отрапортовать успех, даже - если граф после реиндекса фактически пуст. -- **Стало:** после индексации сверяется `node_count` с количеством - обработанных файлов; при явном расхождении статус — `degraded`. - Дополнительно: если мягкий реиндекс дал `degraded`, инструмент - автоматически повторяет его с `force=true` один раз (поле - `auto_retried_with_force`), прежде чем возвращать ответ клиенту. -- **Эффект:** типовой случай рассинхронизации инкрементального состояния - теперь устраняется без ручного повторного вызова с `force=true`. - -## 5. Структурированные причины «symbol not found» - -**Файлы:** `node_resolution.rs`, `mcp/server.rs`. - -- **Было:** все инструменты при неудачной резолюции возвращали одну и ту - же общую фразу без указания причины (неверный URI? неиндексированный - файл? пустой workspace?). -- **Стало:** `enum SymbolNotFoundReason` (`InvalidUri`, `WorkspaceNotIndexed`, - `FileNotIndexed`) с кодом и actionable-подсказкой на каждый случай, - плюс машиночитаемый `suggested_next_queries()` — подключены во всех - 8 инструментах, резолвящих `uri+line`/`nodeId`. -- **Эффект:** агент получает конкретное объяснение причины отказа и - готовую подсказку, что вызвать дальше, вместо непрозрачной общей - ошибки. - -## 6. `match_confidence` в ответах uri+line-инструментов - -**Файлы:** `node_resolution.rs`, `callers.rs`, `symbol_info.rs`, -`call_graph.rs`, `impact.rs`. - -- **Было:** уровень доверия к резолюции виден только опционально - (`used_fallback`, поле присутствует лишь при `true`). -- **Стало:** добавлено поле `match_confidence: "exact" | "fallback"`, - присутствующее в ответе всегда. -- **Эффект:** единообразная, всегда доступная проверка достоверности - ответа без реконструкции состояния из опционального поля. - -## 7. `codegraph_probe_symbol` — новый лёгкий инструмент - -**Файлы:** `domain/probe_symbol.rs` (новый), `mcp/server.rs`, `mcp/tools.rs`. - -- **Задача:** проверить «попал ли запрос в правильный символ» без - full-context ответа. -- **Реализация:** принимает `uri+line`/`nodeId`, возвращает только - `name`/`type`/`node_id`/`uri`/`line_start`/`line_end`/`used_fallback`/ - `match_confidence` — без source, без callers/callees. -- **Эффект:** на порядок более дешёвая по токенам проверка резолюции по - сравнению с полным `get_symbol_info`. - -## 8. `get_detailed_symbol`: дублирование callers/callees + `compact` - -**Файлы:** `symbol_info.rs`, `mcp/server.rs`, `mcp/tools.rs`, -`handlers/ai_query.rs`. - -- **Было:** `get_detailed_symbol` считал callers/callees дважды — один раз - внутри `get_symbol_info` (вложенно в `symbol.callers`/`symbol.callees`), - второй раз отдельным вызовом `get_callers`/`get_callees` с теми же - аргументами — итоговый JSON содержал один и тот же список каждого - caller'а дважды, плюс лишний повторный обход графа на сервере. -- **Стало:** `DetailedSymbolMeta` — вариант без вложенных callers/callees; - top-level `callers`/`callees` берутся из уже посчитанного результата. - Добавлен opt-in `compact: bool` (default `false`) — обрезает списки до - 5 записей с полями `*_truncated`. -- **Эффект:** устранено дублирование данных в ответе и лишний обход - графа; для символов с большим числом caller'ов доступен компактный - режим ответа. - -## 9. Секционный `get_edit_context` - -**Файлы:** `edit_context.rs`, `mcp/server.rs`, `mcp/tools.rs`. - -- **Было:** все 5 секций (`symbol`/`callers`/`tests`/`memories`/ - `recentChanges`), включая дорогое поле `code`, вычислялись безусловно. -- **Стало:** добавлены переключатели `includeSymbol`/`includeCallers`/ - `includeTests`/`includeMemories`/`includeRecentChanges` и лимиты - `maxCallers`/`maxTests` — секции пропускаются **на этапе вычисления**, - а не только сериализации. По умолчанию поведение не изменилось. -- **Эффект:** клиент может запросить только нужные секции и снизить - нагрузку на сервер, а не только объём ответа. - -## 10. `find_nearest_node` резолвился в `CodeFile`-узел вместо символа - -**Файл:** `node_resolution.rs`. - -- **Было:** узел файла (`NodeType::CodeFile`) создаётся без - `line_start`/`line_end`; чтение через не-`Option` геттеры молча - подставляло `0`, что давало диапазон `0..0`. При запросе с - `target_line == 0` (частый дефолт, если клиент не указал строку) такой - узел формально «выигрывал» у любой реальной функции/класса как - «самое тесное совпадение» (`range_size = 0`) — резолвер возвращал файл - вместо символа, без пометки fallback, с `code: ""` - и пустыми callers/tests. -- **Стало:** `find_nearest_node` использует `line_start_opt`/`line_end_opt` - и пропускает узлы без явно проставленного диапазона строк. Если в файле - нет ни одного символа с реальным диапазоном, резолвер честно возвращает - `None` вместо фейкового совпадения на файл. -- **Эффект:** запрос с `line: 0` (или любой строкой вне размеченных - символов) теперь резолвится в ближайший реальный символ файла, а не в - бесполезный узел файла. - -## 11. Вызовы `self.method()`/`cls.method()` не попадали в call-граф (Python) - -**Файлы:** `codegraph-python/src/extractor.rs`, -`codegraph-python/src/parser_impl.rs`. - -- **Было:** два независимых дефекта одновременно исключали из графа - подавляющее большинство вызовов внутри Python-класса: - 1. `node_map` при добавлении методов регистрировался только под голым - именем (`"method_name"`), а вызовы извлекались с квалифицированным - именем caller'а (`"ClassName.method_name"`) — поиск `caller` в - `node_map` всегда проваливался, вызов уходил в межфайловое - разрешение вместо локального ребра. - 2. `extract_callee_name` для `self.helper()` возвращала весь текст узла - (`"self.helper"`) вместо голого имени (`"helper"`) — такая строка не - совпадала ни с одной записью `node_map`. -- **Стало:** методы регистрируются в `node_map` дополнительно под - квалифицированным ключом; `extract_callee_name` для `self.`/`cls.` - возвращает голое имя метода. Заодно исправлено связывание - `Contains`-рёбер класс→метод при одноимённых методах в разных классах - одного файла (раньше могли связаться с чужим методом). -- **Эффект:** вызовы вида `self.method()`/`cls.method()` — то есть - большинство вызовов внутри Python-класса — теперь становятся - `Calls`-рёбрами в графе; `get_callers`/`get_callees`/`get_call_graph`/ - `analyze_impact` на методах перестали систематически недооценивать - связи. - -## 12. `codegraph_index_health` — новый инструмент диагностики индекса - -**Файлы:** `mcp/server.rs`, `mcp/tools.rs`. - -- **Задача:** дать агенту дешёвый способ проверить, не устарел ли индекс, - и увидеть состояние всех известных namespace одним вызовом (вместо - нескольких MCP-сессий). -- **Реализация:** переиспользует уже существующую инфраструктуру - (`_registry:` записи в общей `graph.db`, `graph_db_generation()`, - `GitExecutor::head_commit()`, `IndexState::all_hashes()`). Отдаёт - текущее состояние графа, generation, git HEAD, число изменившихся - файлов с момента индексации, список других namespace и - `suggested_next_queries` при обнаруженном дрейфе. -- **Эффект:** агент может проверить актуальность индекса и увидеть все - проиндексированные проекты без дополнительных round-trip'ов. - -## Проверка - -- `cargo test -p codegraph-server` — 369 unit-тестов, 0 упавших. -- `cargo test -p codegraph-python` — 35 unit-тестов, 0 упавших. -- Живые прогоны через `--run-tool` на реальном многофайловом workspace - подтвердили каждый фикс отдельно (см. историю коммитов для деталей). diff --git a/README.md b/README.md index 3e37728..7b9ad96 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,13 @@ If it saves you time, consider [sponsoring on GitHub](https://github.com/sponsor --- +## Authors + +- [Andrey Vasilevsky](https://github.com/anvanster) — creator & maintainer +- [Aleksei Nekrasov](https://github.com/Znbiz) — contributor + +--- + ## License Apache-2.0