From b238152785e16053f1f55d2396ddb65361c125da Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 15:57:59 +0000 Subject: [PATCH 1/3] fix(mcp): suggest nearest symbols on node_not_found PrimitiveNotFoundV1 now carries up to five served symbols within a bounded edit distance of the requested id, simple name, or qualified name. Node, impact, and rename-preview misses share one suggester, and the CLI's nearest-tool lookup reuses the same bounded edit distance from tracedecay-domain. Fixes #3014 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/tracedecay-cli/src/tool_command.rs | 3 +- .../tracedecay-cli/src/tool_command/args.rs | 25 ++------ .../tracedecay-cli/src/tool_command/tests.rs | 7 --- .../src/retrieval/primitive_surface.rs | 4 ++ crates/tracedecay-domain/src/text.rs | 43 +++++++++++++- .../tracedecay-mcp/src/handlers/graph/mod.rs | 57 +++++++++++++++++-- .../src/handlers/graph/navigation.rs | 8 ++- .../src/handlers/graph/rename_preview.rs | 2 +- .../mcp_handler_test/impact_behavior_test.rs | 32 +++++++++++ .../mcp_handler_test/node_behavior_test.rs | 48 ++++++++++++++++ sdks/typescript/src/operations.ts | 10 ++-- 11 files changed, 194 insertions(+), 45 deletions(-) diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index 3c089e6693..6cfe533b28 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -94,8 +94,7 @@ use args::{ }; #[cfg(test)] use args::{ - edit_distance, finalize_arrays, parse_invocation_with_stdin, - parse_whole_payload_invocation_with_stdin, + finalize_arrays, parse_invocation_with_stdin, parse_whole_payload_invocation_with_stdin, }; #[cfg(test)] use serde_json::Map; diff --git a/crates/tracedecay-cli/src/tool_command/args.rs b/crates/tracedecay-cli/src/tool_command/args.rs index d28ae432c5..a1e113b9c2 100644 --- a/crates/tracedecay-cli/src/tool_command/args.rs +++ b/crates/tracedecay-cli/src/tool_command/args.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use serde_json::{Map, Value}; use tracedecay_domain::errors::{Result, TraceDecayError}; +use tracedecay_domain::text::edit_distance_within; use tracedecay_mcp::tool_errors::TOOL_ARGUMENTS_INVALID; use tracedecay_mcp::{ToolDefinition, resolve_property_schema, short_tool_name}; @@ -543,8 +544,10 @@ fn nearest_by_edit_distance( let max_distance = max_typo_distance(target); candidates .into_iter() - .map(|candidate| (edit_distance(target, &candidate), candidate)) - .filter(|(distance, _)| *distance <= max_distance) + .filter_map(|candidate| { + edit_distance_within(target, &candidate, max_distance) + .map(|distance| (distance, candidate)) + }) .min_by_key(|(distance, _)| *distance) .map(|(_, candidate)| candidate) } @@ -564,24 +567,6 @@ pub(crate) fn nearest_tool_name(canonical: &str, defs: &[ToolDefinition]) -> Opt ) } -/// Classic two-row Levenshtein distance; property and tool names are short so -/// the quadratic cost is irrelevant. -pub(super) fn edit_distance(a: &str, b: &str) -> usize { - let a: Vec = a.chars().collect(); - let b: Vec = b.chars().collect(); - let mut prev: Vec = (0..=b.len()).collect(); - let mut curr = vec![0; b.len() + 1]; - for (i, ca) in a.iter().enumerate() { - curr[0] = i + 1; - for (j, cb) in b.iter().enumerate() { - let cost = usize::from(ca != cb); - curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1); - } - std::mem::swap(&mut prev, &mut curr); - } - prev[b.len()] -} - /// A `-flag` (single dash) token whose name matches a known property is a /// typo'd flag, not a positional. Returns the kebab-case flag name to suggest. fn single_dash_flag_typo(raw: &str, props: &Map) -> Option { diff --git a/crates/tracedecay-cli/src/tool_command/tests.rs b/crates/tracedecay-cli/src/tool_command/tests.rs index 24b5fabd5e..ff203f12d8 100644 --- a/crates/tracedecay-cli/src/tool_command/tests.rs +++ b/crates/tracedecay-cli/src/tool_command/tests.rs @@ -903,13 +903,6 @@ fn unknown_tool_suggestion_finds_nearest_name() { assert_eq!(suggestion.as_deref(), Some("dead_code")); } -#[test] -fn edit_distance_basics() { - assert_eq!(edit_distance("limit", "limit"), 0); - assert_eq!(edit_distance("limt", "limit"), 1); - assert_eq!(edit_distance("", "abc"), 3); -} - #[test] fn validation_skips_opaque_schemas() { // A definition without properties must be treated as opaque: no unknown diff --git a/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs b/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs index 1ffa219dc6..4277f25443 100644 --- a/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs +++ b/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs @@ -625,6 +625,10 @@ pub struct PrimitiveNotFoundV1 { pub reason_code: String, pub node_id: String, pub message: String, + /// Nearest served symbols by edit distance to the requested id, by id, + /// simple name, or qualified name. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub suggestions: Vec, /// The worktree verdict a served graph read opens with. #[serde(default, skip_serializing_if = "Option::is_none")] pub freshness: Option, diff --git a/crates/tracedecay-domain/src/text.rs b/crates/tracedecay-domain/src/text.rs index e176a56ee0..f706f86c6a 100644 --- a/crates/tracedecay-domain/src/text.rs +++ b/crates/tracedecay-domain/src/text.rs @@ -121,13 +121,52 @@ pub fn utf8_prefix_at_or_before(text: &str, max_bytes: usize) -> &str { &text[..text.floor_char_boundary(max_bytes)] } +/// Levenshtein distance between `left` and `right` when it is at most +/// `limit`, counted in characters. `None` means more than `limit` edits. +#[must_use] +pub fn edit_distance_within(left: &str, right: &str, limit: usize) -> Option { + let left: Vec = left.chars().collect(); + let right: Vec = right.chars().collect(); + if left.len().abs_diff(right.len()) > limit { + return None; + } + let mut previous: Vec = (0..=right.len()).collect(); + let mut current = vec![0; right.len() + 1]; + for (left_index, left_character) in left.iter().enumerate() { + current[0] = left_index + 1; + for (right_index, right_character) in right.iter().enumerate() { + current[right_index + 1] = (previous[right_index] + + usize::from(left_character != right_character)) + .min(previous[right_index + 1] + 1) + .min(current[right_index] + 1); + } + // A row's minimum never decreases in later rows. + if current.iter().min().is_some_and(|minimum| *minimum > limit) { + return None; + } + std::mem::swap(&mut previous, &mut current); + } + Some(previous[right.len()]).filter(|distance| *distance <= limit) +} + #[cfg(test)] mod tests { use super::{ - blank_json_comments, collapse_whitespace, fold_control_characters, forward_slash_text, - utf8_prefix_at_or_before, + blank_json_comments, collapse_whitespace, edit_distance_within, fold_control_characters, + forward_slash_text, utf8_prefix_at_or_before, }; + #[test] + fn edit_distance_within_counts_character_edits_up_to_the_limit() { + assert_eq!(edit_distance_within("limit", "limit", 0), Some(0)); + assert_eq!(edit_distance_within("limt", "limit", 1), Some(1)); + assert_eq!(edit_distance_within("", "abc", 3), Some(3)); + assert_eq!(edit_distance_within("", "abc", 2), None); + assert_eq!(edit_distance_within("kitten", "sitting", 3), Some(3)); + assert_eq!(edit_distance_within("kitten", "sitting", 2), None); + assert_eq!(edit_distance_within("caf\u{e9}", "cafe", 1), Some(1)); + } + #[test] fn json_comments_become_spaces_and_keep_offsets_and_strings() { let text = "{\n // a\n \"url\": \"http://x/y\", /* b\n c */ \"n\": 1\n}"; diff --git a/crates/tracedecay-mcp/src/handlers/graph/mod.rs b/crates/tracedecay-mcp/src/handlers/graph/mod.rs index 90e57558d3..6260f1630d 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/mod.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/mod.rs @@ -33,8 +33,16 @@ pub use verified::{ required_graph_metadata, single_graph_adjacency_batch, traverse_verified_neighbors, }; +use std::cell::RefCell; +use std::collections::BTreeMap; + use tracedecay_contracts::retrieval::PrimitiveNotFoundV1; +use tracedecay_domain::SymbolOccurrenceId; use tracedecay_domain::errors::{Result, TraceDecayError}; +use tracedecay_domain::text::edit_distance_within; +use tracedecay_graph_query::VerifiedGraphQuery; + +use primitive_surface::symbol_location; use crate::{ToolResult, text_tool_result}; @@ -51,18 +59,55 @@ pub(super) fn require_positive_depth(max_depth: u32) -> Result<()> { Ok(()) } -pub fn node_not_found(node_id: &str) -> Result { - not_found_tool_result(&node_not_found_result(node_id)) -} +const NODE_SUGGESTION_LIMIT: usize = 5; -pub(crate) fn node_not_found_result(node_id: &str) -> PrimitiveNotFoundV1 { - PrimitiveNotFoundV1 { +/// Not-found answer carrying the served symbols nearest to the requested +/// id: a typo'd or truncated occurrence id, or a name passed as an id. +pub(crate) fn node_not_found_result( + graph: &VerifiedGraphQuery, + node_id: &str, + occurrence: &SymbolOccurrenceId, +) -> Result { + let query = occurrence.as_str(); + let max_distance = (query.chars().count() / 3).clamp(1, 3); + let nearest = RefCell::new(BTreeMap::<(usize, String), SymbolOccurrenceId>::new()); + graph.find_symbols( + &|candidate, _, metadata| { + let names = metadata.map_or([None, None], |metadata| { + [ + Some(metadata.simple_name.as_str()), + Some(metadata.qualified_name.as_str()), + ] + }); + let distance = std::iter::once(candidate.as_str()) + .chain(names.into_iter().flatten()) + .filter_map(|text| edit_distance_within(query, text, max_distance)) + .min(); + if let Some(distance) = distance { + let mut nearest = nearest.borrow_mut(); + nearest.insert((distance, candidate.as_str().to_owned()), candidate.clone()); + if nearest.len() > NODE_SUGGESTION_LIMIT { + nearest.pop_last(); + } + } + false + }, + 1, + )?; + let mut suggestions = Vec::new(); + for occurrence in nearest.into_inner().into_values() { + if let Some(symbol) = graph.symbol_summary(&occurrence)? { + suggestions.push(symbol_location(&symbol)?); + } + } + Ok(PrimitiveNotFoundV1 { status: "not_found".to_owned(), reason_code: "node_not_found".to_owned(), node_id: node_id.to_owned(), message: format!("Node not found: {node_id}"), + suggestions, freshness: None, - } + }) } pub fn not_found_tool_result(output: &PrimitiveNotFoundV1) -> Result { diff --git a/crates/tracedecay-mcp/src/handlers/graph/navigation.rs b/crates/tracedecay-mcp/src/handlers/graph/navigation.rs index b139f5f69c..5f87b57f23 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/navigation.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/navigation.rs @@ -34,8 +34,10 @@ pub async fn compute_impact( if graph.symbol_summary(&occurrence)?.is_none() { return Ok(graph_tool_completion( GraphToolResultV1::Impact(ImpactResultV1::NotFound(node_not_found_result( + graph, &request.node_id, - ))), + &occurrence, + )?)), Vec::new(), )); } @@ -163,8 +165,10 @@ pub async fn compute_node( } None => Ok(graph_tool_completion( GraphToolResultV1::Node(NodeResultV1::NotFound(Box::new(node_not_found_result( + graph, &request.node_id, - )))), + &occurrence, + )?))), Vec::new(), )), } diff --git a/crates/tracedecay-mcp/src/handlers/graph/rename_preview.rs b/crates/tracedecay-mcp/src/handlers/graph/rename_preview.rs index 5d35c4b394..0c181e8b64 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/rename_preview.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/rename_preview.rs @@ -131,7 +131,7 @@ pub async fn compute_rename_preview( let Some(node) = graph.symbol_summary(&occurrence)? else { return Ok(graph_tool_completion( GraphToolResultV1::RenamePreview(RenamePreviewPrimitiveOutcomeV1::NotFound( - node_not_found_result(&request.node_id), + node_not_found_result(graph, &request.node_id, &occurrence)?, )), Vec::new(), )); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs index e8e4b75228..b79b227d2b 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs @@ -174,6 +174,38 @@ async fn impact_reports_callers_by_depth_and_refuses_invalid_requests() { "an unknown seed reports not-found like tracedecay_node: {unknown}" ); + let misspelled = handle_real_server_tool_call_raw( + &server, + "tracedecay_impact", + json!({ "node_id": "calee", "format": "json" }), + ) + .await; + let misspelled_text = misspelled + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("impact response missing text: {misspelled}")); + assert_eq!( + serde_json::from_str::(misspelled_text).expect("not-found JSON"), + json!({ + "freshness": {"state": "fresh"}, + "status": "not_found", + "reason_code": "node_not_found", + "node_id": "calee", + "message": "Node not found: calee", + "suggestions": [{ + "node_id": ids["callee"], + "name": "callee", + "qualified_name": "src/lib.rs::callee", + "kind": "function", + "file": "src/lib.rs", + "start_line": 3, + "end_line": 5, + "unavailable_fields": ["attrs_start_line"] + }] + }), + "a misspelled seed suggests the symbol it names: {misspelled}" + ); + assert_refused( &server, json!({ "node_id": " ", "format": "json" }), diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs index 93918bb33e..09459bd14e 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs @@ -180,6 +180,54 @@ async fn tracedecay_node_reports_declared_symbols_and_typed_refusals() { fixture.harness.shutdown().await; } +/// An unknown id answers with the nearest served symbols, whether the caller +/// typo'd the occurrence id or passed a misspelled name in its place. +#[tokio::test] +async fn tracedecay_node_suggests_the_nearest_symbols_for_an_unknown_id() { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), SOURCE).unwrap(); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production node server"); + wait_for_current_graph(&server).await; + let fetch_id = occurrence_id(&server, "fetch_value", "function").await; + let fetch_location = json!({ + "node_id": fetch_id, + "name": "fetch_value", + "qualified_name": "src/lib.rs::fetch_value", + "kind": "function", + "file": "src/lib.rs", + "start_line": 2, + "end_line": 11, + "unavailable_fields": ["attrs_start_line"] + }); + + let mut typo_id = fetch_id.clone(); + let last = typo_id.pop().expect("occurrence id"); + typo_id.push(if last == '0' { '1' } else { '0' }); + for unknown in [typo_id.as_str(), "fetch_valu"] { + let missing = node_call(&server, json!({"node_id": unknown})).await; + assert_eq!(missing["result"]["isError"], true, "{missing}"); + assert_eq!( + parse_json(&tool_text(&missing)), + json!({ + "freshness": {"state": "fresh"}, + "status": "not_found", + "reason_code": "node_not_found", + "node_id": unknown, + "message": format!("Node not found: {unknown}"), + "suggestions": [fetch_location] + }) + ); + } + + fixture.harness.shutdown().await; +} + fn fetch_details(id: &str) -> Value { details( id, diff --git a/sdks/typescript/src/operations.ts b/sdks/typescript/src/operations.ts index 61ed8f0434..f7c3e8dfb5 100644 --- a/sdks/typescript/src/operations.ts +++ b/sdks/typescript/src/operations.ts @@ -739,7 +739,7 @@ export type PrimitiveIndexingStateV1 = { readonly latest_generation?: string | n export type PrimitiveLaneCompleteV1 = "complete"; export type PrimitiveLaneStateV1 = "stale" | "partial" | "unavailable"; export type PrimitiveLaneStatusV1 = PrimitiveLaneCompleteV1 | { readonly generation?: string | null; readonly reason?: string | null; readonly status: PrimitiveLaneStateV1; readonly [key: string]: unknown }; -export type PrimitiveNotFoundV1 = { readonly freshness?: PrimitiveSearchFreshnessV1 | null; readonly message: string; readonly node_id: string; readonly reason_code: string; readonly status: string }; +export type PrimitiveNotFoundV1 = { readonly freshness?: PrimitiveSearchFreshnessV1 | null; readonly message: string; readonly node_id: string; readonly reason_code: string; readonly status: string; readonly suggestions?: readonly PrimitiveSymbolLocationV1[] }; export type PrimitiveRecallV1 = "full" | "partial"; export type PrimitiveSearchCoverageV1 = { readonly exact: PrimitiveLaneStatusV1; readonly graph: PrimitiveLaneStatusV1; readonly lexical: PrimitiveLaneStatusV1; readonly recall: PrimitiveRecallV1 }; export type PrimitiveSearchFreshnessV1 = { readonly indexing?: PrimitiveIndexingStateV1 | null; readonly state: PrimitiveFreshnessStateV1 }; @@ -2485,7 +2485,7 @@ const DEFINITIONS = { PrimitiveLaneCompleteV1: {"enum":["complete"],"type":"string"}, PrimitiveLaneStateV1: {"enum":["stale","partial","unavailable"],"type":"string"}, PrimitiveLaneStatusV1: {"anyOf":[{"$ref":"#/$defs/PrimitiveLaneCompleteV1"},{"properties":{"generation":{"type":["string","null"]},"reason":{"type":["string","null"]},"status":{"$ref":"#/$defs/PrimitiveLaneStateV1"}},"required":["status"],"type":"object"}]}, - PrimitiveNotFoundV1: {"additionalProperties":false,"properties":{"freshness":{"anyOf":[{"$ref":"#/$defs/PrimitiveSearchFreshnessV1"},{"type":"null"}],"description":"The worktree verdict a served graph read opens with."},"message":{"type":"string"},"node_id":{"type":"string"},"reason_code":{"type":"string"},"status":{"type":"string"}},"required":["status","reason_code","node_id","message"],"type":"object"}, + PrimitiveNotFoundV1: {"additionalProperties":false,"properties":{"freshness":{"anyOf":[{"$ref":"#/$defs/PrimitiveSearchFreshnessV1"},{"type":"null"}],"description":"The worktree verdict a served graph read opens with."},"message":{"type":"string"},"node_id":{"type":"string"},"reason_code":{"type":"string"},"status":{"type":"string"},"suggestions":{"description":"Nearest served symbols by edit distance to the requested id, by id,\nsimple name, or qualified name.","items":{"$ref":"#/$defs/PrimitiveSymbolLocationV1"},"type":"array"}},"required":["status","reason_code","node_id","message"],"type":"object"}, PrimitiveRecallV1: {"enum":["full","partial"],"type":"string"}, PrimitiveSearchCoverageV1: {"additionalProperties":false,"properties":{"exact":{"$ref":"#/$defs/PrimitiveLaneStatusV1"},"graph":{"$ref":"#/$defs/PrimitiveLaneStatusV1"},"lexical":{"$ref":"#/$defs/PrimitiveLaneStatusV1"},"recall":{"$ref":"#/$defs/PrimitiveRecallV1"}},"required":["exact","lexical","graph","recall"],"type":"object"}, PrimitiveSearchFreshnessV1: {"additionalProperties":false,"description":"Freshness verdict carried by every search and context response. `indexing`\nis present exactly when the state is `possibly_stale`.","properties":{"indexing":{"anyOf":[{"$ref":"#/$defs/PrimitiveIndexingStateV1"},{"type":"null"}]},"state":{"$ref":"#/$defs/PrimitiveFreshnessStateV1"}},"required":["state"],"type":"object"}, @@ -3241,7 +3241,7 @@ const SCHEMAS: readonly CanonicalJsonSchema[] = [ {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"limit":{"description":"Maximum number of hotspots to return (default: 10, at most 100).","format":"uint32","minimum":0,"type":["integer","null"]}},"title":"HotspotsSurfaceRequestV1","type":"object"}, {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"HotspotV1":DEFINITIONS.HotspotV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"freshness":{"anyOf":[{"$ref":"#/$defs/PrimitiveSearchFreshnessV1"},{"type":"null"}],"description":"The worktree verdict a served graph read opens with."},"hotspot_count":{"format":"uint64","minimum":0,"type":"integer"},"hotspots":{"items":{"$ref":"#/$defs/HotspotV1"},"type":"array"},"unavailable_fields":{"description":"Names a measurement this result could not make. `churn` means the\nranking is connectivity alone.","items":{"type":"string"},"type":"array"}},"required":["hotspot_count","hotspots"],"title":"HotspotsResultV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"max_depth":{"format":"uint32","minimum":0,"type":["integer","null"]},"node_id":{"type":"string"}},"required":["node_id"],"title":"NodeDepthSurfaceRequestV1","type":"object"}, - {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"ImpactNodeV1":DEFINITIONS.ImpactNodeV1,"ImpactRadiusV1":DEFINITIONS.ImpactRadiusV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/ImpactRadiusV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"ImpactResultV1"}, + {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"ImpactNodeV1":DEFINITIONS.ImpactNodeV1,"ImpactRadiusV1":DEFINITIONS.ImpactRadiusV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1,"PrimitiveSymbolLocationV1":DEFINITIONS.PrimitiveSymbolLocationV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/ImpactRadiusV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"ImpactResultV1"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"limit":{"description":"Maximum number of results to return (default: 10, at most 100).","format":"uint32","minimum":0,"type":["integer","null"]},"path":{"description":"Filter to files under this directory path (e.g. 'src/main/java').","type":["string","null"]}},"title":"InheritanceDepthSurfaceRequestV1","type":"object"}, {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"InheritanceDepthEntryV1":DEFINITIONS.InheritanceDepthEntryV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"freshness":{"anyOf":[{"$ref":"#/$defs/PrimitiveSearchFreshnessV1"},{"type":"null"}],"description":"The worktree verdict a served graph read opens with."},"ranking":{"items":{"$ref":"#/$defs/InheritanceDepthEntryV1"},"type":"array"},"result_count":{"format":"uint64","minimum":0,"type":"integer"}},"required":["result_count","ranking"],"title":"InheritanceDepthResultV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Exact public input accepted by `tracedecay_insert_at`.","properties":{"anchor":{"type":"string"},"before":{"default":false,"type":"boolean"},"content":{"type":"string"},"dry_run":{"default":false,"type":"boolean"},"expected_state":{"description":"Exact `preview_digest`/`expected_state` returned by the dry run. Apply\nre-resolves the typed plan and rejects any candidate-state drift.","type":["string","null"]},"idempotency_key":{"type":["string","null"]},"path":{"type":"string"},"verify":{"default":false,"type":"boolean"}},"required":["path","anchor","content"],"title":"InsertAtSurfaceRequestV1","type":"object"}, @@ -3272,7 +3272,7 @@ const SCHEMAS: readonly CanonicalJsonSchema[] = [ {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Exact public input accepted by `tracedecay_multi_str_replace`.","properties":{"dry_run":{"default":false,"type":"boolean"},"expected_state":{"description":"Exact `preview_digest`/`expected_state` returned by the dry run. Apply\nre-resolves the typed plan and rejects any candidate-state drift.","type":["string","null"]},"idempotency_key":{"type":["string","null"]},"path":{"type":"string"},"replacements":{"description":"Ordered `[old, new]` pairs, matching the existing MCP tool wire form.","items":{"maxItems":2,"minItems":2,"prefixItems":[{"type":"string"},{"type":"string"}],"type":"array"},"type":"array"},"verify":{"default":false,"type":"boolean"}},"required":["path","replacements"],"title":"MultiStrReplaceSurfaceRequestV1","type":"object"}, {"$defs":{"NativeIntegrationTransactionId":DEFINITIONS.NativeIntegrationTransactionId},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"transaction_id":{"$ref":"#/$defs/NativeIntegrationTransactionId"}},"required":["transaction_id"],"title":"NativeIntegrationStatusSurfaceRequest","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"node_id":{"type":"string"}},"required":["node_id"],"title":"NodeSurfaceRequestV1","type":"object"}, - {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"ComplexityAnalysisV1":DEFINITIONS.ComplexityAnalysisV1,"NodeDetailsV1":DEFINITIONS.NodeDetailsV1,"NodeExpansionCostV1":DEFINITIONS.NodeExpansionCostV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/NodeDetailsV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"NodeResultV1"}, + {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"ComplexityAnalysisV1":DEFINITIONS.ComplexityAnalysisV1,"NodeDetailsV1":DEFINITIONS.NodeDetailsV1,"NodeExpansionCostV1":DEFINITIONS.NodeExpansionCostV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1,"PrimitiveSymbolLocationV1":DEFINITIONS.PrimitiveSymbolLocationV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/NodeDetailsV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"NodeResultV1"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"One project-scoped horizon for the canonical Observatory and Costs models.","properties":{"window_days":{"default":14,"format":"uint16","maximum":365,"minimum":1,"type":"integer"}},"title":"ObservatoryReadRequestV1","type":"object"}, {"$defs":{"AnalyticsModeReadModelV1":DEFINITIONS.AnalyticsModeReadModelV1,"AnalyticsModeV1":DEFINITIONS.AnalyticsModeV1,"ComparisonDispositionV1":DEFINITIONS.ComparisonDispositionV1,"CostsReadModelV1":DEFINITIONS.CostsReadModelV1,"CoverageStateV1":DEFINITIONS.CoverageStateV1,"LatencyDistributionReadModelV1":DEFINITIONS.LatencyDistributionReadModelV1,"MetricCalibrationV1":DEFINITIONS.MetricCalibrationV1,"MetricCohortV1":DEFINITIONS.MetricCohortV1,"MetricCoverageV1":DEFINITIONS.MetricCoverageV1,"MetricEvidenceClassV1":DEFINITIONS.MetricEvidenceClassV1,"MetricProvenanceV1":DEFINITIONS.MetricProvenanceV1,"MetricSourceV1":DEFINITIONS.MetricSourceV1,"MetricTemporalV1":DEFINITIONS.MetricTemporalV1,"MetricUncertaintyV1":DEFINITIONS.MetricUncertaintyV1,"MetricValueV1":DEFINITIONS.MetricValueV1,"ObservabilityHorizonV1":DEFINITIONS.ObservabilityHorizonV1,"ObservatoryReadModelV1":DEFINITIONS.ObservatoryReadModelV1,"PerformanceComparisonReadModelV1":DEFINITIONS.PerformanceComparisonReadModelV1,"ProviderLatencyReadModelV1":DEFINITIONS.ProviderLatencyReadModelV1,"RejectedArgumentAnalyticsV1":DEFINITIONS.RejectedArgumentAnalyticsV1,"RejectedArgumentErrorClassV1":DEFINITIONS.RejectedArgumentErrorClassV1,"RejectedArgumentGroupV1":DEFINITIONS.RejectedArgumentGroupV1,"RejectedArgumentNameV1":DEFINITIONS.RejectedArgumentNameV1,"RejectedArgumentSurfaceV1":DEFINITIONS.RejectedArgumentSurfaceV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Canonical project-scoped observability and costs read models.","properties":{"costs":{"$ref":"#/$defs/CostsReadModelV1"},"observatory":{"$ref":"#/$defs/ObservatoryReadModelV1"}},"required":["observatory","costs"],"title":"ObservatoryReadResultV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"kinds":{"items":{"type":"string"},"type":["array","null"]},"limit":{"format":"uint32","minimum":0,"type":["integer","null"]},"source_dir":{"type":"string"}},"required":["source_dir"],"title":"PortOrderSurfaceRequestV1","type":"object"}, @@ -3298,7 +3298,7 @@ const SCHEMAS: readonly CanonicalJsonSchema[] = [ {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"title":"RemoteStatusSurfaceRequestV1","type":"object"}, {"$defs":{"AuthorityEpoch":DEFINITIONS.AuthorityEpoch,"BrainId":DEFINITIONS.BrainId,"BrainNodeId":DEFINITIONS.BrainNodeId,"CurrentRemoteAuthorityStateV1":DEFINITIONS.CurrentRemoteAuthorityStateV1,"CurrentRemoteAuthorityV1":DEFINITIONS.CurrentRemoteAuthorityV1,"DoctorCoverageCompletenessV1":DEFINITIONS.DoctorCoverageCompletenessV1,"ProjectionGenerationId":DEFINITIONS.ProjectionGenerationId,"RemoteAuthorityUnavailableReasonV1":DEFINITIONS.RemoteAuthorityUnavailableReasonV1,"RemoteListenerReadV1":DEFINITIONS.RemoteListenerReadV1,"RemoteOperationalReadinessV1":DEFINITIONS.RemoteOperationalReadinessV1,"RemoteOperationalStatusV1":DEFINITIONS.RemoteOperationalStatusV1,"RemoteSpoolOperationalStatusV1":DEFINITIONS.RemoteSpoolOperationalStatusV1,"RemoteWriterFenceV1":DEFINITIONS.RemoteWriterFenceV1,"ShardId":DEFINITIONS.ShardId,"UtcMicros":DEFINITIONS.UtcMicros},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Typed read of the Remote Brain operational plane as observed from the\nmounted daemon authorities. Every operator surface (Doctor, CLI, MCP,\ndashboard) reads this one shape; `Unavailable` is reserved for a genuinely\nunmounted or unreadable authority, never a rendering shortcut.","oneOf":[{"properties":{"coverage":{"$ref":"#/$defs/DoctorCoverageCompletenessV1"},"kind":{"const":"observed","type":"string"},"listener":{"$ref":"#/$defs/RemoteListenerReadV1"},"status":{"$ref":"#/$defs/RemoteOperationalStatusV1"}},"required":["kind","listener","status","coverage"],"type":"object"},{"properties":{"kind":{"const":"unconfigured","type":"string"}},"required":["kind"],"type":"object"},{"properties":{"kind":{"const":"unavailable","type":"string"}},"required":["kind"],"type":"object"}],"title":"RemoteOperationalStatusReadV1"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"new_name":{"type":["string","null"]},"node_id":{"type":"string"}},"required":["node_id"],"title":"RenamePreviewPrimitiveRequestV1","type":"object"}, - {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1,"RenamePreviewNodeV1":DEFINITIONS.RenamePreviewNodeV1,"RenamePreviewPrimitiveResultV1":DEFINITIONS.RenamePreviewPrimitiveResultV1,"RenamePreviewReferenceV1":DEFINITIONS.RenamePreviewReferenceV1,"RenamePreviewTextOnlyMatchV1":DEFINITIONS.RenamePreviewTextOnlyMatchV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/RenamePreviewPrimitiveResultV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"RenamePreviewPrimitiveOutcomeV1"}, + {"$defs":{"CodeIndexBuildBlockedReasonV1":DEFINITIONS.CodeIndexBuildBlockedReasonV1,"CodeIndexConvergenceParkedV1":DEFINITIONS.CodeIndexConvergenceParkedV1,"CodeIndexStalenessStateV1":DEFINITIONS.CodeIndexStalenessStateV1,"PrimitiveFreshnessStateV1":DEFINITIONS.PrimitiveFreshnessStateV1,"PrimitiveIndexingStateV1":DEFINITIONS.PrimitiveIndexingStateV1,"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"PrimitiveSearchFreshnessV1":DEFINITIONS.PrimitiveSearchFreshnessV1,"PrimitiveSymbolLocationV1":DEFINITIONS.PrimitiveSymbolLocationV1,"RenamePreviewNodeV1":DEFINITIONS.RenamePreviewNodeV1,"RenamePreviewPrimitiveResultV1":DEFINITIONS.RenamePreviewPrimitiveResultV1,"RenamePreviewReferenceV1":DEFINITIONS.RenamePreviewReferenceV1,"RenamePreviewTextOnlyMatchV1":DEFINITIONS.RenamePreviewTextOnlyMatchV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/RenamePreviewPrimitiveResultV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"RenamePreviewPrimitiveOutcomeV1"}, {"$defs":{"ManifestDigest":DEFINITIONS.ManifestDigest,"RenamePreviewAcceptanceV1":DEFINITIONS.RenamePreviewAcceptanceV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Exact public input accepted by `tracedecay_rename_symbol`.\n\nThe five identity fields consume the preview's exact symbol identity; the\nflattened control consumes its exact candidate-state digest.","properties":{"accepted_preview":{"anyOf":[{"$ref":"#/$defs/RenamePreviewAcceptanceV1"},{"type":"null"}],"description":"Exact output identity from the accepted dry-run preview. Required when\n`dry_run=false` and omitted when computing a preview."},"dry_run":{"default":true,"type":"boolean"},"expected_state":{"description":"Exact `preview_digest`/`expected_state` returned by the dry run. Apply\nre-resolves the typed plan and rejects any candidate-state drift.","type":["string","null"]},"file":{"type":"string"},"idempotency_key":{"type":["string","null"]},"kind":{"type":"string"},"new_name":{"type":"string"},"node_id":{"description":"Canonical `SymbolOccurrenceId` returned by `tracedecay_rename_preview`.","type":"string"},"old_name":{"type":"string"},"qualified_name":{"type":"string"},"verify":{"default":true,"type":"boolean"}},"required":["node_id","qualified_name","kind","file","old_name","new_name"],"title":"RenameSymbolSurfaceRequestV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Exact public input accepted by `tracedecay_replace_symbol`.","properties":{"dry_run":{"default":false,"type":"boolean"},"expected_state":{"description":"Exact `preview_digest`/`expected_state` returned by the dry run. Apply\nre-resolves the typed plan and rejects any candidate-state drift.","type":["string","null"]},"idempotency_key":{"type":["string","null"]},"new_source":{"type":"string"},"symbol":{"type":"string"},"verify":{"default":false,"type":"boolean"}},"required":["symbol","new_source"],"title":"ReplaceSymbolSurfaceRequestV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"handle":{"description":"The required `handle` argument copied exactly from a truncated MCP\nresponse envelope.","type":"string"},"max_chars":{"description":"Maximum characters requested for this page. Values above the safe\nresponse-frame budget are clamped.","format":"uint64","minimum":1,"type":["integer","null"]},"offset":{"default":0,"description":"Character offset into the immutable stored response (default: 0). Use\nthe prior page's next_offset.","format":"uint64","minimum":0,"type":["integer","null"]}},"required":["handle"],"title":"RetrieveSurfaceRequestV1","type":"object"}, From ec0d3287e3876d4d3ea3fe2bcdfe3fbb74845a1e Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 16:10:13 +0000 Subject: [PATCH 2/3] fix(mcp): suggest only symbols with a location Unbound edge targets have no extraction metadata or logical file, so symbol_location cannot render them; admitting one turned a not-found answer into a graph-corrupt error. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../tracedecay-mcp/src/handlers/graph/mod.rs | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/tracedecay-mcp/src/handlers/graph/mod.rs b/crates/tracedecay-mcp/src/handlers/graph/mod.rs index 6260f1630d..00197bbb6c 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/mod.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/mod.rs @@ -72,17 +72,26 @@ pub(crate) fn node_not_found_result( let max_distance = (query.chars().count() / 3).clamp(1, 3); let nearest = RefCell::new(BTreeMap::<(usize, String), SymbolOccurrenceId>::new()); graph.find_symbols( - &|candidate, _, metadata| { - let names = metadata.map_or([None, None], |metadata| { - [ - Some(metadata.simple_name.as_str()), - Some(metadata.qualified_name.as_str()), - ] - }); - let distance = std::iter::once(candidate.as_str()) - .chain(names.into_iter().flatten()) - .filter_map(|text| edit_distance_within(query, text, max_distance)) - .min(); + &|candidate, binding, metadata| { + // Only symbols `symbol_location` can render; unbound edge targets + // carry neither extraction metadata nor a logical file. + let Some(metadata) = metadata else { + return false; + }; + if binding + .and_then(|binding| binding.logical_path.as_ref()) + .is_none() + { + return false; + } + let distance = [ + candidate.as_str(), + metadata.simple_name.as_str(), + metadata.qualified_name.as_str(), + ] + .into_iter() + .filter_map(|text| edit_distance_within(query, text, max_distance)) + .min(); if let Some(distance) = distance { let mut nearest = nearest.borrow_mut(); nearest.insert((distance, candidate.as_str().to_owned()), candidate.clone()); From a82c0649d37de7472669a2e5683381091fcb0607 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 5 Oct 2026 04:09:55 -0700 Subject: [PATCH 3/3] fix(mcp): avoid cloned edit-check target --- crates/tracedecay-mcp/src/handlers/edit_check.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-mcp/src/handlers/edit_check.rs b/crates/tracedecay-mcp/src/handlers/edit_check.rs index 7def148940..afe586f83e 100644 --- a/crates/tracedecay-mcp/src/handlers/edit_check.rs +++ b/crates/tracedecay-mcp/src/handlers/edit_check.rs @@ -335,10 +335,11 @@ mod tests { file: "caller.rs".to_owned(), line: 0, }; +let symbols = [target, caller]; let error = signature_edits( root.path(), - std::slice::from_ref(&target), - &[target.clone(), caller], + std::slice::from_ref(&symbols[0]), + &symbols, &[], ) .unwrap_err();